From 307161f465a2525a6e586d60bc38733fe4907d59 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Fri, 7 Aug 2026 02:38:58 +0800 Subject: [PATCH] fix: restore PDF pen capture and overhaul sticky/pens/pages 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 --- lib/editor/canvas/pen_canvas.dart | 13 +- lib/editor/canvas/pen_editor_screen.dart | 230 ++++--- lib/editor/canvas/pen_note_screen.dart | 595 ++++++++++++------ lib/editor/canvas/pen_palette_widgets.dart | 128 +++- lib/editor/canvas/pen_slide_screen.dart | 89 +-- lib/editor/canvas/sticky_note_overlay.dart | 254 ++++---- lib/editor/input/pen_config.dart | 2 +- lib/editor/input/pen_slots.dart | 213 +++++++ .../persistence/sidecar_repository.dart | 39 +- lib/main.dart | 5 +- lib/models/scratch_link.dart | 35 +- lib/services/vault_service.dart | 3 + test/pen_brush_color_memory_test.dart | 64 +- test/pen_slots_test.dart | 75 +++ test/sidecar_repository_test.dart | 20 + test/vault_service_test.dart | 1 + 16 files changed, 1279 insertions(+), 487 deletions(-) create mode 100644 lib/editor/input/pen_slots.dart create mode 100644 test/pen_slots_test.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 67d2e51..108789f 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -85,6 +85,8 @@ class PenCanvas extends StatefulWidget { this.allowFingerDrawing = false, this.minScale = 0.5, this.maxScale = 8.0, + this.scaleEnabled = true, + this.panEnabled = true, this.onPenDebug, this.thinning = kDefaultPenThinning, this.pressureGamma = kNaturalPressureGamma, @@ -163,6 +165,13 @@ class PenCanvas extends StatefulWidget { final double minScale; final double maxScale; + /// When false, the canvas cannot be pinch-zoomed (sticky notes lock this so + /// writing isn't fighting an inner transform). + final bool scaleEnabled; + + /// When false, one-finger pan is disabled (sticky notes often lock pan too). + final bool panEnabled; + /// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`. final double thinning; @@ -804,7 +813,7 @@ class _PenCanvasState extends State { // governs touch/mouse: suppress pan while a single-finger / mouse stroke is // in progress (finger-drawing mode); a 2nd pointer cancels the stroke first // so a pinch re-enables pan/zoom immediately. - final panEnabled = _drawPointer == null; + final panEnabled = widget.panEnabled && _drawPointer == null; // Mirror committed strokes into the revision-tracked store (only re-mirrors // when the parent handed us a new list identity). @@ -823,7 +832,7 @@ class _PenCanvasState extends State { minScale: widget.minScale, maxScale: widget.maxScale, panEnabled: panEnabled, - scaleEnabled: true, + scaleEnabled: widget.scaleEnabled, child: SizedBox( width: widget.pageSize.width, height: widget.pageSize.height, diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 01e1beb..efb8bf5 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -48,6 +48,7 @@ import '../engine/undo_stack.dart'; import '../input/diagnostic_logger.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; +import '../input/pen_slots.dart'; import '../input/pressure_curve.dart' show PressureCurve, kNaturalPressureFloor; import '../pdf/pen_capture_region.dart'; @@ -166,6 +167,9 @@ class _PenEditorScreenState extends State { /// Loaded asynchronously in initState; null until ready. PenConfigController? _penConfig; + /// Independent OneNote-style pen slots (brush + color + width per slot). + PenSlotsController? _penSlots; + // ── Persistence ──────────────────────────────────────────────────────────── /// Per-file sidecar persistence. Identity is the source file PATH (the @@ -195,7 +199,7 @@ class _PenEditorScreenState extends State { static const double _kScaleGlitchHi = 1.18; /// A single-frame focal-midpoint jump beyond this is a touch misread → drop. - static const double _kFocalGlitchPx = 100.0; + static const double _kFocalGlitchPx = 64.0; /// Matrix scale captured at the current baseline (gesture start or the last /// pointer-count re-baseline). Null when no pinch is active. @@ -250,11 +254,6 @@ class _PenEditorScreenState extends State { // path (they disable pen capture), so they stay as their own booleans. EditorToolKind _tool = EditorToolKind.brush; - /// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The - /// highlighter tool always uses [BrushKind.highlighter]. Local state only for - /// this increment (not persisted — TODO(brush-persist-selection)). - BrushKind _penBrush = BrushKind.fountainPen; - /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; @@ -270,22 +269,12 @@ class _PenEditorScreenState extends State { Offset? _selectLast; bool _selectDragging = false; - /// rnote-style per-brush color memory: each brush (and the highlighter) - /// remembers its own color. Selecting a brush restores its color; picking a - /// color updates ONLY the active brush's entry. In-memory only for this - /// increment (TODO(brush-color-persist)). - final Map _brushColors = { - BrushKind.fountainPen: Colors.black, - BrushKind.ballpoint: Colors.blue, - BrushKind.pencil: Colors.green, - BrushKind.highlighter: Colors.orange, - }; + /// Highlighter keeps its own color (not a pen slot). + Color _highlighterColor = Colors.orange; - /// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter, - /// else the selected pen brush). - BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter - ? BrushKind.highlighter - : _penBrush; + /// Active pen brush from the selected slot (fallback until slots load). + BrushKind get _penBrush => + _penSlots?.active.brush ?? BrushKind.fountainPen; /// When true the "select text" tool button is latched on. bool _selectTextTool = false; @@ -327,16 +316,18 @@ class _PenEditorScreenState extends State { static const _uuid = Uuid(); - /// The active drawing color = the active brush's remembered color. - Color get _color => _brushColors[_activeColorBrush] ?? Colors.black; + /// The active drawing color: highlighter tool uses [_highlighterColor], else + /// the active pen slot's color. + Color get _color => _tool == EditorToolKind.highlighter + ? _highlighterColor + : (_penSlots?.active.color ?? Colors.black); bool _allowFingerDrawing = false; /// Whether the viewer currently has a non-empty text selection (drives the /// "highlight selection" action's enabled state). bool _hasSelection = false; - /// Pen width as a fraction of page width (base; pressure thins it down). - static const double _penWidthFraction = 0.006; + /// Fallback highlighter width as a fraction of page width. static const double _highlighterWidthFraction = 0.02; static const List _palette = kInkPalette; @@ -348,8 +339,7 @@ class _PenEditorScreenState extends State { !_selectTextMode && !_placeLinkMode && !_removeHighlightMode && - !_textMode && - _expandedSticky == null; + !_textMode; /// True when the eraser tool is active. bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode; @@ -373,15 +363,23 @@ class _PenEditorScreenState extends State { } Future _initPenConfig() async { - final controller = await PenConfigController.load(); + final results = await Future.wait([ + PenConfigController.load(), + PenSlotsController.load(), + ]); + final config = results[0] as PenConfigController; + final slots = results[1] as PenSlotsController; if (!mounted) { - controller.dispose(); + config.dispose(); + slots.dispose(); return; } - controller.addListener(_onPenConfigChanged); + config.addListener(_onPenConfigChanged); + slots.addListener(_onPenSlotsChanged); setState(() { - _penConfig = controller; - _allowFingerDrawing = controller.value.fingerDrawing; + _penConfig = config; + _penSlots = slots; + _allowFingerDrawing = config.value.fingerDrawing; }); } @@ -390,6 +388,10 @@ class _PenEditorScreenState extends State { _syncBarrelSelectText(); } + void _onPenSlotsChanged() { + if (mounted) setState(() {}); + } + void _onHwPenChanged() { _syncBarrelSelectText(); _dispatchHwSideButton(); @@ -513,6 +515,8 @@ class _PenEditorScreenState extends State { _liveStrokeVN.dispose(); _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); + _penSlots?.removeListener(_onPenSlotsChanged); + _penSlots?.dispose(); PenInputService.instance.removeListener(_onHwPenChanged); PenInputService.instance.stop(); DiagnosticLogger.instance.stop(); @@ -700,7 +704,11 @@ class _PenEditorScreenState extends State { } void _onFingerPointer(PointerEvent event) { - if (!_allowFingerDrawing || !_penCaptureEnabled) return; + // Finger ink is independent of sticky expand; still blocked in text-select modes. + if (!_allowFingerDrawing) return; + if (_selectTextMode || _placeLinkMode || _removeHighlightMode || _textMode) { + return; + } if (event.kind != PointerDeviceKind.touch) return; if (event is PointerDownEvent) { @@ -995,7 +1003,7 @@ class _PenEditorScreenState extends State { double _currentStrokeWidth() => _tool == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) - : (_penConfig?.value.penWidth ?? _penWidthFraction); + : (_penSlots?.active.width ?? 0.006); PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter ? PenStrokeKind.highlighter @@ -1523,6 +1531,16 @@ class _PenEditorScreenState extends State { }); } + void _updateScratchLink(ScratchLink next) { + final i = _scratchLinks.indexWhere((s) => s.id == next.id); + if (i < 0) return; + setState(() { + _scratchLinks[i] = next; + if (_expandedSticky?.id == next.id) _expandedSticky = next; + }); + _repo?.scheduleScratchLinkUpsert(next); + } + void _closeSticky() { if (!mounted) return; final open = _expandedSticky; @@ -2099,14 +2117,59 @@ class _PenEditorScreenState extends State { // delete. Sized in screen px so the tap target stays usable at any // zoom; positioned at (nx*pageW, ny*pageH). for (final link in linksOnPage) + if (_expandedSticky?.id != link.id) + Positioned( + left: link.nx * pageW - _kMarkerSize / 2, + top: link.ny * pageH - _kMarkerSize / 2, + width: _kMarkerSize, + height: _kMarkerSize, + child: _ScratchLinkMarker( + onTap: () => _openScratchLink(link), + onLongPress: () => _confirmDeleteScratchLink(link), + ), + ), + // Expanded sticky glued to page (moves/scales with PDF zoom). + if (_expandedSticky != null && + _repo != null && + _expandedSticky!.pageIndex == pageIndex) Positioned( - left: link.nx * pageW - _kMarkerSize / 2, - top: link.ny * pageH - _kMarkerSize / 2, - width: _kMarkerSize, - height: _kMarkerSize, - child: _ScratchLinkMarker( - onTap: () => _openScratchLink(link), - onLongPress: () => _confirmDeleteScratchLink(link), + left: (_expandedSticky!.nx * pageW).clamp(0.0, pageW * 0.85), + top: (_expandedSticky!.ny * pageH).clamp(0.0, pageH * 0.85), + width: (_expandedSticky!.nw * pageW).clamp(120.0, pageW), + height: (_expandedSticky!.nh * pageH).clamp(140.0, pageH), + child: StickyNoteOverlay( + key: ValueKey(_expandedSticky!.id), + link: _expandedSticky!, + repo: _repo!, + brush: _penBrush, + color: _color, + tool: _tool, + strokeWidth: _penSlots?.active.width ?? 0.006, + allowFingerDrawing: _allowFingerDrawing, + onClose: _closeSticky, + onDelete: () async { + final link = _expandedSticky!; + _closeSticky(); + await _confirmDeleteScratchLink(link); + }, + onDragPx: (dx, dy) { + final link = _expandedSticky; + if (link == null || pageW <= 0 || pageH <= 0) return; + final next = link.copyWith( + nx: (link.nx + dx / pageW).clamp(0.0, 0.95), + ny: (link.ny + dy / pageH).clamp(0.0, 0.95), + ); + _updateScratchLink(next); + }, + onResizePx: (dx, dy) { + final link = _expandedSticky; + if (link == null || pageW <= 0 || pageH <= 0) return; + final next = link.copyWith( + nw: (link.nw + dx / pageW).clamp(0.18, 0.95), + nh: (link.nh + dy / pageH).clamp(0.18, 0.95), + ); + _updateScratchLink(next); + }, ), ), ]; @@ -2119,10 +2182,21 @@ class _PenEditorScreenState extends State { // outside the arena by PenCaptureRegion) is never stolen. viewerOverlayBuilder: (context, size, handleLinkTap) { return [ + // Finger ink FIRST (under pinch). Always translucent so 2nd finger + // still reaches the pinch recognizer above — opaque stole pinch. + Positioned.fill( + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onFingerPointer, + onPointerMove: _onFingerPointer, + onPointerUp: _onFingerPointer, + onPointerCancel: _onFingerPointer, + child: const SizedBox.expand(), + ), + ), + // Pinch on TOP of finger listener (Stack hit-tests last child first). Positioned.fill( child: RawGestureDetector( - // translucent (NOT opaque): the touch must ALSO hit-test pdfrx - // underneath so its pan recognizer can win the 1-finger case. behavior: HitTestBehavior.translucent, gestures: { _TwoFingerPinch: @@ -2137,18 +2211,6 @@ class _PenEditorScreenState extends State { child: const SizedBox.expand(), ), ), - Positioned.fill( - child: Listener( - behavior: _fingerStrokeActive - ? HitTestBehavior.opaque - : HitTestBehavior.translucent, - onPointerDown: _onFingerPointer, - onPointerMove: _onFingerPointer, - onPointerUp: _onFingerPointer, - onPointerCancel: _onFingerPointer, - child: const SizedBox.expand(), - ), - ), Positioned.fill( child: PenCaptureRegion( captureEnabled: _penCaptureEnabled, @@ -2166,26 +2228,7 @@ class _PenEditorScreenState extends State { onBookmark: _addBookmark, ), ), - if (_expandedSticky != null && _repo != null) - Positioned( - right: 20, - top: 72, - child: StickyNoteOverlay( - key: ValueKey(_expandedSticky!.id), - link: _expandedSticky!, - repo: _repo!, - brush: _penBrush, - color: _color, - tool: _tool, - allowFingerDrawing: _allowFingerDrawing, - onClose: _closeSticky, - onDelete: () async { - final link = _expandedSticky!; - _closeSticky(); - await _confirmDeleteScratchLink(link); - }, - ), - ), + // Sticky is rendered in pageOverlaysBuilder (page-anchored). ]; }, ), @@ -2205,20 +2248,25 @@ class _PenEditorScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // OneNote-style: each pen is its own slot with remembered color. - for (final b in kPenToolBrushes) + // OneNote-style: each pen slot restores brush + color + thickness. + for (final slot in _penSlots?.slots ?? kDefaultPenSlots()) PenSlotButton( - kind: b, + kind: slot.brush, selected: _tool == EditorToolKind.brush && - _penBrush == b && + (_penSlots?.activeId ?? 'slot_0') == slot.id && _penCaptureEnabled, - color: _brushColors[b] ?? Colors.black, - tooltip: brushLabel(b, l), + color: slot.color, + widthHint: slot.width, + tooltip: brushLabel(slot.brush, l), onPressed: () { - setState(() => _penBrush = b); + _penSlots?.select(slot.id); _setTool(EditorToolKind.brush); }, ), + ThicknessPickerButton( + width: _penSlots?.active.width ?? 0.006, + onChanged: (w) => _penSlots?.setActiveWidth(w), + ), ToolButton( icon: Icons.brush_outlined, selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled, @@ -2323,6 +2371,10 @@ class _PenEditorScreenState extends State { ), PaletteDivider(cs: cs), for (final c in _palette) _colorDot(c, cs), + ThicknessPickerButton( + width: _penSlots?.active.width ?? 0.006, + onChanged: (w) => _penSlots?.setActiveWidth(w), + ), PaletteDivider(cs: cs), ToolButton( icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, @@ -2423,11 +2475,17 @@ class _PenEditorScreenState extends State { } Widget _colorDot(Color c, ColorScheme cs) { - // Selected against the ACTIVE brush's remembered color; a tap updates only - // that brush's entry (rnote per-brush color memory). Inert in select mode. - final selected = _color == c && !_isSelect; + // Selected against the ACTIVE slot (or highlighter) color; a tap updates + // only that entry. Inert highlight in select mode. + final selected = _color.toARGB32() == c.toARGB32() && !_isSelect; return GestureDetector( - onTap: () => setState(() => _brushColors[_activeColorBrush] = c), + onTap: () { + if (_tool == EditorToolKind.highlighter) { + setState(() => _highlighterColor = c); + } else { + _penSlots?.setActiveColor(c); + } + }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 28, diff --git a/lib/editor/canvas/pen_note_screen.dart b/lib/editor/canvas/pen_note_screen.dart index 8bbb3d9..152ef83 100644 --- a/lib/editor/canvas/pen_note_screen.dart +++ b/lib/editor/canvas/pen_note_screen.dart @@ -19,6 +19,7 @@ import '../engine/stroke_model.dart'; import '../persistence/sidecar_repository.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; +import '../input/pen_slots.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../layout/viewport_fit.dart'; import '../notebook/ink_stroke_adapter.dart'; @@ -42,47 +43,42 @@ class PenNoteScreen extends ConsumerStatefulWidget { class _PenNoteScreenState extends ConsumerState { static const _uuid = Uuid(); - /// Live strokes in normalized coords (the canvas source of truth). Persisted - /// back to the note as InkStroke via the adapter on save. - List _strokes = const []; + /// Per-page live strokes in normalized coords (the canvas source of truth). + final Map> _strokesByPage = {}; - /// Snapshot-before-change undo/redo of the stroke list. + /// Current page index (0-based) and total page count (min 1). + int _pageIndex = 0; + int _pageCount = 1; + + /// Snapshot-before-change undo/redo scoped to the current page. Cleared on + /// page switch so undo never crosses pages. final List> _undo = []; final List> _redo = []; + bool _showPageScrubber = false; + double? _pageScrub; + /// The single active-tool state (shared model across the 3 editors). EditorToolKind _tool = EditorToolKind.brush; - /// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The - /// highlighter tool always uses [BrushKind.highlighter]; local state only for - /// this increment (not persisted — see TODO(brush-persist-selection)). - BrushKind _penBrush = BrushKind.fountainPen; - /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; /// Index of the currently selected committed stroke (SELECT tool), or null. int? _selectedStroke; - /// rnote-style per-brush color memory: each brush (and the highlighter) - /// remembers its own color. Selecting a brush restores its color; picking a - /// color updates ONLY the active brush's entry. In-memory only for this - /// increment (TODO(brush-color-persist)). - final Map _brushColors = { - BrushKind.fountainPen: Colors.black, - BrushKind.ballpoint: Colors.blue, - BrushKind.pencil: Colors.green, - BrushKind.highlighter: Colors.orange, - }; + /// Highlighter keeps its own color (not a pen slot). + Color _highlighterColor = Colors.orange; - /// The brush whose color the color-dots edit: the highlighter when the - /// highlighter tool is active, otherwise the selected pen brush. - BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter - ? BrushKind.highlighter - : _penBrush; + /// Active pen brush from the selected slot (fallback until slots load). + BrushKind get _penBrush => + _penSlots?.active.brush ?? BrushKind.fountainPen; - /// The active drawing color (the active brush's remembered color). - Color get _color => _brushColors[_activeColorBrush] ?? Colors.black; + /// Active drawing color: highlighter tool uses [_highlighterColor], else the + /// active pen slot's color. + Color get _color => _tool == EditorToolKind.highlighter + ? _highlighterColor + : (_penSlots?.active.color ?? Colors.black); /// The page-background template painted behind the ink (rnote-style). Default /// blank; persisted per-notebook in the sidecar. @@ -96,23 +92,28 @@ class _PenNoteScreenState extends ConsumerState { /// Persistence flows through this note's `notebook.badnote.json` sidecar. String? _notePath; - /// Per-file sidecar persistence sink (strokes page 0 + title), debounced and - /// atomic — replaces the old SQLite Note/noteListProvider write path here. + /// Per-file sidecar persistence sink (strokes + pageCount + title), debounced + /// and atomic — replaces the old SQLite Note/noteListProvider write path here. SidecarRepository? _repo; - /// Page index a standalone note's strokes live under in the sidecar. - static const int _notePageIndex = 0; - final TextEditingController _titleController = TextEditingController(); PenConfigController? _penConfig; + PenSlotsController? _penSlots; final TransformationController _transform = TransformationController(); - static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; static const List _palette = kInkPalette; + /// Current page's stroke list (PenCanvas source of truth). + List get _strokes => + _strokesByPage.putIfAbsent(_pageIndex, () => []); + + set _strokes(List value) { + _strokesByPage[_pageIndex] = value; + } + @override void initState() { super.initState(); @@ -123,7 +124,7 @@ class _PenNoteScreenState extends ConsumerState { _titleController.text = note.title; // Seed from the in-memory note's strokes (e.g. tests) until the sidecar // load resolves and (if present) overrides with persisted strokes. - _strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage); + _strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage); } else { _titleController.text = 'Untitled'; } @@ -131,9 +132,8 @@ class _PenNoteScreenState extends ConsumerState { if (_notePath != null) _initPersistence(_notePath!); } - /// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted - /// strokes / a title, hydrate the canvas from them. Strokes load as page-0 - /// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor). + /// Open the note's `notebook.badnote.json` sidecar and hydrate every page of + /// strokes plus title / background / pageCount. Future _initPersistence(String notePath) async { final repo = await SidecarRepository.open(notePath, docType: 'notebook'); if (!mounted) { @@ -141,19 +141,47 @@ class _PenNoteScreenState extends ConsumerState { return; } _repo = repo; - final loaded = repo.loadedStrokes[_notePageIndex]; setState(() { - if (loaded != null && loaded.isNotEmpty) { - _strokes = [for (final es in loaded) _penStrokeFromEditor(es)]; - } - final title = repo.loadedTitle; - if (title != null && title.isNotEmpty) { - _titleController.text = title; - } - _background = noteBackgroundFromName(repo.loadedBackground); + _hydrateFromRepo(repo); }); } + /// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1, + /// highest stroke key + 1). Persists pageCount when the sidecar omitted it. + void _hydrateFromRepo(SidecarRepository repo) { + // Only replace in-memory strokes when the sidecar actually holds ink — + // otherwise keep the seed from widget.note (widget tests / cold open). + if (repo.loadedStrokes.isNotEmpty) { + _strokesByPage.clear(); + for (final entry in repo.loadedStrokes.entries) { + if (entry.value.isEmpty) continue; + _strokesByPage[entry.key] = [ + for (final es in entry.value) _penStrokeFromEditor(es), + ]; + } + } + final fromKeys = _strokesByPage.isEmpty + ? 1 + : _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1; + final declared = repo.sidecar.pageCount ?? 1; + _pageCount = declared > fromKeys ? declared : fromKeys; + if (_pageCount < 1) _pageCount = 1; + if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1; + _undo.clear(); + _redo.clear(); + _selectedStroke = null; + + final title = repo.loadedTitle; + if (title != null && title.isNotEmpty) { + _titleController.text = title; + } + _background = noteBackgroundFromName(repo.loadedBackground); + + if (repo.sidecar.pageCount != _pageCount) { + repo.schedulePageCountSave(_pageCount); + } + } + /// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush /// is persisted on the EditorStroke now, so carry it through; old sidecars /// without the field decode to fountainPen (back-compat default). @@ -170,15 +198,23 @@ class _PenNoteScreenState extends ConsumerState { ); Future _initPenConfig() async { - final controller = await PenConfigController.load(); + final results = await Future.wait([ + PenConfigController.load(), + PenSlotsController.load(), + ]); + final config = results[0] as PenConfigController; + final slots = results[1] as PenSlotsController; if (!mounted) { - controller.dispose(); + config.dispose(); + slots.dispose(); return; } - controller.addListener(_onPenConfigChanged); + config.addListener(_onPenConfigChanged); + slots.addListener(_onPenSlotsChanged); setState(() { - _penConfig = controller; - _allowFingerDrawing = controller.value.fingerDrawing; + _penConfig = config; + _penSlots = slots; + _allowFingerDrawing = config.value.fingerDrawing; }); } @@ -186,6 +222,10 @@ class _PenNoteScreenState extends ConsumerState { if (mounted) setState(() {}); } + void _onPenSlotsChanged() { + if (mounted) setState(() {}); + } + @override void dispose() { // Flush any pending sidecar write before tearing down (atomic write @@ -197,6 +237,8 @@ class _PenNoteScreenState extends ConsumerState { } _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); + _penSlots?.removeListener(_onPenSlotsChanged); + _penSlots?.dispose(); _titleController.dispose(); _transform.dispose(); super.dispose(); @@ -256,11 +298,60 @@ class _PenNoteScreenState extends ConsumerState { // ── Persistence ────────────────────────────────────────────────────────────── - /// Persist the live pen strokes + title to the note's `notebook.badnote.json` - /// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title - /// field), debounced/atomic via [SidecarRepository]. Creates the notebook - /// folder lazily on first save when the screen was opened without a path. - /// Refreshes the home list and triggers local OCR for search indexing. + /// Schedule a stroke save for [pageIndex] (defaults to current) without + /// flushing. Used when switching pages so ink isn't lost mid-edit. + void _schedulePageStrokeSave([int? pageIndex]) { + final repo = _repo; + if (repo == null) return; + final idx = pageIndex ?? _pageIndex; + final pageStrokes = _strokesByPage[idx] ?? const []; + final editorStrokes = [ + 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, () => []); + _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 _save() async { if (!_dirty) return; final notifier = ref.read(noteListProvider.notifier); @@ -284,12 +375,16 @@ class _PenNoteScreenState extends ConsumerState { } final repo = _repo!; - final editorStrokes = [ - for (final s in _strokes) EditorStroke.fromPenStroke(s), - ]; repo.scheduleTitleSave(title); repo.scheduleBackgroundSave(_background.name); - repo.scheduleStrokeSave(_notePageIndex, editorStrokes); + repo.schedulePageCountSave(_pageCount); + // Persist every page that has (or had) strokes in this session. Empty pages + // clear their sidecar entry via scheduleStrokeSave. + for (final idx in _strokesByPage.keys.toList()..sort()) { + _schedulePageStrokeSave(idx); + } + // Also ensure the current page is written even if never putIfAbsent'd empty. + _schedulePageStrokeSave(_pageIndex); await repo.flush(); // Refresh the home list so the title/recency update is visible on return. @@ -297,10 +392,12 @@ class _PenNoteScreenState extends ConsumerState { if (!mounted) return; setState(() => _dirty = false); - // Build an in-memory Note (id = note path) for OCR/FTS indexing only. + // Build an in-memory Note (id = note path) for OCR/FTS indexing only — + // flatten all pages into one stroke list. final inkStrokes = [ - for (final s in _strokes) - inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now), + for (final page in _strokesByPage.values) + for (final s in page) + inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now), ]; _runLocalOcr(Note( id: _notePath!, @@ -341,7 +438,7 @@ class _PenNoteScreenState extends ConsumerState { double get _strokeWidth => _tool == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) - : (_penConfig?.value.penWidth ?? _penWidthFraction); + : (_penSlots?.active.width ?? 0.006); // ── SELECT tool: select / move / delete (reuses the undo stacks) ───────────── @@ -416,13 +513,20 @@ class _PenNoteScreenState extends ConsumerState { ), ), ), - // Title pill (bottom-center). + // Title + page chrome (bottom-center). SafeArea( child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 16), - child: _buildTitlePill(cs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPagePill(cs), + const SizedBox(height: 8), + _buildTitlePill(cs), + ], + ), ), ), ), @@ -520,160 +624,175 @@ class _PenNoteScreenState extends ConsumerState { borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // OneNote-style: each pen is its own slot with remembered color. - for (final b in kPenToolBrushes) - PenSlotButton( - kind: b, - selected: _tool == EditorToolKind.brush && _penBrush == b, - color: _brushColors[b] ?? Colors.black, - tooltip: brushLabelEn(b), - onPressed: () => setState(() { - _penBrush = b; - _tool = EditorToolKind.brush; + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // OneNote-style: each pen slot restores brush + color + thickness. + for (final slot in _penSlots?.slots ?? kDefaultPenSlots()) + PenSlotButton( + kind: slot.brush, + selected: _tool == EditorToolKind.brush && + (_penSlots?.activeId ?? 'slot_0') == slot.id, + color: slot.color, + widthHint: slot.width, + tooltip: brushLabelEn(slot.brush), + onPressed: () { + _penSlots?.select(slot.id); + setState(() => _tool = EditorToolKind.brush); + }, + ), + ToolButton( + icon: Icons.brush_outlined, + selected: _tool == EditorToolKind.highlighter, + tooltip: 'Highlighter', + onPressed: () => + setState(() => _tool = EditorToolKind.highlighter), + ), + ToolButton( + icon: Icons.cleaning_services_outlined, + selected: _tool == EditorToolKind.eraser, + tooltip: 'Eraser', + onPressed: () => setState(() => _tool = EditorToolKind.eraser), + ), + // Select (cursor) + shape tools. + ToolButton( + icon: Icons.ads_click, + selected: _tool == EditorToolKind.select, + tooltip: 'Select', + onPressed: () => setState(() => _tool = EditorToolKind.select), + ), + ShapePickerButton( + selected: _shapeKind, + active: _tool == EditorToolKind.shape, + tooltip: 'Shape', + labelFor: shapeLabelEn, + onActivate: () => setState(() => _tool = EditorToolKind.shape), + onSelected: (s) => setState(() { + _shapeKind = s; + _tool = EditorToolKind.shape; }), ), - ToolButton( - icon: Icons.brush_outlined, - selected: _tool == EditorToolKind.highlighter, - tooltip: 'Highlighter', - onPressed: () => - setState(() => _tool = EditorToolKind.highlighter), - ), - ToolButton( - icon: Icons.cleaning_services_outlined, - selected: _tool == EditorToolKind.eraser, - tooltip: 'Eraser', - onPressed: () => setState(() => _tool = EditorToolKind.eraser), - ), - // Select (cursor) + shape tools. - ToolButton( - icon: Icons.ads_click, - selected: _tool == EditorToolKind.select, - tooltip: 'Select', - onPressed: () => setState(() => _tool = EditorToolKind.select), - ), - ShapePickerButton( - selected: _shapeKind, - active: _tool == EditorToolKind.shape, - tooltip: 'Shape', - labelFor: shapeLabelEn, - onActivate: () => setState(() => _tool = EditorToolKind.shape), - onSelected: (s) => setState(() { - _shapeKind = s; - _tool = EditorToolKind.shape; - }), - ), - if (_tool == EditorToolKind.select && _selectedStroke != null) + if (_tool == EditorToolKind.select && _selectedStroke != null) + ToolButton( + icon: Icons.delete_outline, + selected: false, + tooltip: 'Delete selection', + onPressed: _deleteSelected, + ), + PaletteDivider(cs: cs), ToolButton( - icon: Icons.delete_outline, + icon: Icons.undo, selected: false, - tooltip: 'Delete selection', - onPressed: _deleteSelected, + tooltip: 'Undo', + onPressed: _undo.isNotEmpty ? _performUndo : null, ), - PaletteDivider(cs: cs), - ToolButton( - icon: Icons.undo, - selected: false, - tooltip: 'Undo', - onPressed: _undo.isNotEmpty ? _performUndo : null, - ), - ToolButton( - icon: Icons.redo, - selected: false, - tooltip: 'Redo', - onPressed: _redo.isNotEmpty ? _performRedo : null, - ), - PaletteDivider(cs: cs), - for (final c in _palette) _colorDot(c, cs), - PaletteDivider(cs: cs), - // Page-background template picker (rnote-style: blank / dots / ruled - // / grid / cornell). Persists per-notebook in the sidecar. - PopupMenuButton( - tooltip: 'Page background', - initialValue: _background, - onSelected: (b) { - setState(() { - _background = b; - _dirty = true; - }); - }, - itemBuilder: (context) => [ - for (final b in NoteBackground.values) - PopupMenuItem( - value: b, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(noteBackgroundIcon(b), size: 20), - const SizedBox(width: 10), - Text(noteBackgroundLabel(b)), - if (b == _background) ...[ - const SizedBox(width: 8), - Icon(Icons.check, size: 18, color: cs.primary), + ToolButton( + icon: Icons.redo, + selected: false, + tooltip: 'Redo', + onPressed: _redo.isNotEmpty ? _performRedo : null, + ), + PaletteDivider(cs: cs), + for (final c in _palette) _colorDot(c, cs), + ThicknessPickerButton( + width: _penSlots?.active.width ?? 0.006, + onChanged: (w) => _penSlots?.setActiveWidth(w), + ), + PaletteDivider(cs: cs), + // Page-background template picker (rnote-style: blank / dots / ruled + // / grid / cornell). Persists per-notebook in the sidecar. + PopupMenuButton( + tooltip: 'Page background', + initialValue: _background, + onSelected: (b) { + setState(() { + _background = b; + _dirty = true; + }); + }, + itemBuilder: (context) => [ + for (final b in NoteBackground.values) + PopupMenuItem( + value: b, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(noteBackgroundIcon(b), size: 20), + const SizedBox(width: 10), + Text(noteBackgroundLabel(b)), + if (b == _background) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 18, color: cs.primary), + ], ], - ], + ), ), + ], + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + noteBackgroundIcon(_background), + size: 22, + color: cs.onSurfaceVariant, + ), + Icon( + Icons.arrow_drop_down, + size: 18, + color: cs.onSurfaceVariant, + ), + ], ), - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - noteBackgroundIcon(_background), - size: 22, - color: cs.onSurfaceVariant, - ), - Icon( - Icons.arrow_drop_down, - size: 18, - color: cs.onSurfaceVariant, - ), - ], ), ), - ), - PaletteDivider(cs: cs), - ToolButton( - icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, - selected: _allowFingerDrawing, - tooltip: _allowFingerDrawing - ? 'Finger drawing ON' - : 'Finger drawing OFF (pen only)', - onPressed: _toggleFingerDrawing, - ), - ToolButton( - icon: Icons.settings_outlined, - selected: false, - tooltip: 'Pen settings (width, pressure, eraser…)', - onPressed: _penConfig != null - ? () => showPenSettingsSheet(context, _penConfig!) - : null, - ), - ], + PaletteDivider(cs: cs), + ToolButton( + icon: + _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, + selected: _allowFingerDrawing, + tooltip: _allowFingerDrawing + ? 'Finger drawing ON' + : 'Finger drawing OFF (pen only)', + onPressed: _toggleFingerDrawing, + ), + ToolButton( + icon: Icons.settings_outlined, + selected: false, + tooltip: 'Pen settings (width, pressure, eraser…)', + onPressed: _penConfig != null + ? () => showPenSettingsSheet(context, _penConfig!) + : null, + ), + ], + ), ), ), ); } Widget _colorDot(Color c, ColorScheme cs) { - // Selected against the ACTIVE brush's remembered color. A color tap updates - // only that brush's entry (rnote per-brush color memory). + // Selected against the ACTIVE slot (or highlighter) color. A color tap + // updates only the active slot / highlighter — not other slots. final selected = _color.toARGB32() == c.toARGB32() && _tool != EditorToolKind.eraser && _tool != EditorToolKind.select; return GestureDetector( - onTap: () => setState(() { + onTap: () { if (_tool == EditorToolKind.eraser || _tool == EditorToolKind.select) { - _tool = EditorToolKind.brush; + setState(() => _tool = EditorToolKind.brush); } - _brushColors[_activeColorBrush] = c; - }), + if (_tool == EditorToolKind.highlighter) { + setState(() => _highlighterColor = c); + } else { + _penSlots?.setActiveColor(c); + } + }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric(horizontal: 3), @@ -715,4 +834,88 @@ class _PenNoteScreenState extends ConsumerState { ), ); } + + /// 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, + ), + ], + ), + ), + ), + ], + ); + } } diff --git a/lib/editor/canvas/pen_palette_widgets.dart b/lib/editor/canvas/pen_palette_widgets.dart index fbea529..1e408e4 100644 --- a/lib/editor/canvas/pen_palette_widgets.dart +++ b/lib/editor/canvas/pen_palette_widgets.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import '../../l10n/app_localizations.dart'; import '../engine/brush.dart'; +import '../input/pen_slots.dart'; import 'editor_tool.dart'; /// Shared ink color palette for PDF / note / slide / scratch editors. @@ -87,8 +88,8 @@ IconData shapeIcon(ShapeKind kind) => switch (kind) { ShapeKind.arrow => Icons.arrow_outward, }; -/// OneNote-style pen slot: each brush is its own toolbar button with a color -/// underline (per-brush remembered color). Prefer this over [BrushPickerButton] +/// OneNote-style pen slot: each slot is its own toolbar button with a color +/// underline (slot-remembered color). Prefer this over [BrushPickerButton] /// when the UX wants pens visible side-by-side. class PenSlotButton extends StatelessWidget { const PenSlotButton({ @@ -98,6 +99,7 @@ class PenSlotButton extends StatelessWidget { required this.color, required this.tooltip, required this.onPressed, + this.widthHint, }); final BrushKind kind; @@ -106,11 +108,18 @@ class PenSlotButton extends StatelessWidget { final String tooltip; final VoidCallback onPressed; + /// Optional page-width fraction; when set, underline height scales slightly + /// so thicker slots read visually thicker. Null keeps the fixed 3px bar. + final double? widthHint; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final iconColor = selected ? cs.onSecondaryContainer : cs.onSurfaceVariant; + final barHeight = widthHint == null + ? 3.0 + : (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0); return Tooltip( message: tooltip, child: InkWell( @@ -131,7 +140,7 @@ class PenSlotButton extends StatelessWidget { const SizedBox(height: 3), Container( width: 16, - height: 3, + height: barHeight, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(2), @@ -145,6 +154,119 @@ class PenSlotButton extends StatelessWidget { } } +/// Compact thickness control: S / M / L presets + a custom slider. Writes the +/// chosen page-width fraction via [onChanged] (typically +/// [PenSlotsController.setActiveWidth]). +class ThicknessPickerButton extends StatelessWidget { + const ThicknessPickerButton({ + super.key, + required this.width, + required this.onChanged, + this.tooltip = 'Thickness', + }); + + /// Current stroke width (page-width fraction). + final double width; + + final ValueChanged 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( + tooltip: tooltip, + onSelected: onChanged, + itemBuilder: (context) => [ + PopupMenuItem( + 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( + 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( + 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( + enabled: false, + child: SizedBox( + width: 180, + child: StatefulBuilder( + builder: (context, setLocal) { + final v = width.clamp(kPenSlotWidthMin, kPenSlotWidthMax); + return Slider( + value: v, + min: kPenSlotWidthMin, + max: kPenSlotWidthMax, + onChanged: (next) { + onChanged(next); + setLocal(() {}); + }, + ); + }, + ), + ), + ), + ], + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.line_weight, size: 20, color: cs.onSurfaceVariant), + const SizedBox(width: 2), + Text( + _labelFor(width), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + /// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil). /// /// Highlighter and eraser remain separate tools. Tapping the button opens a diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index 47fb503..b5887b3 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -19,6 +19,7 @@ import '../engine/brush.dart'; import '../engine/shape_geometry.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; +import '../input/pen_slots.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../layout/viewport_fit.dart'; import '../pdf/slide_export.dart'; @@ -58,29 +59,21 @@ class _PenSlideScreenState extends State { /// The single active-tool state (shared model across the 3 editors). EditorToolKind _tool = EditorToolKind.brush; - /// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter - /// brush; local state only (not persisted — TODO(brush-persist-selection)). - BrushKind _penBrush = BrushKind.fountainPen; - /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; /// Index of the currently selected committed stroke (SELECT tool), or null. int? _selectedStroke; - /// rnote-style per-brush color memory (see PenNoteScreen). In-memory only. - final Map _brushColors = { - BrushKind.fountainPen: Colors.black, - BrushKind.ballpoint: Colors.blue, - BrushKind.pencil: Colors.green, - BrushKind.highlighter: Colors.orange, - }; + /// Highlighter keeps its own color (not a pen slot). + Color _highlighterColor = Colors.orange; - BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter - ? BrushKind.highlighter - : _penBrush; + BrushKind get _penBrush => + _penSlots?.active.brush ?? BrushKind.fountainPen; - Color get _color => _brushColors[_activeColorBrush] ?? Colors.black; + Color get _color => _tool == EditorToolKind.highlighter + ? _highlighterColor + : (_penSlots?.active.color ?? Colors.black); bool _allowFingerDrawing = false; bool _needsCenter = true; @@ -88,9 +81,9 @@ class _PenSlideScreenState extends State { double? _scrub; PenConfigController? _penConfig; + PenSlotsController? _penSlots; final TransformationController _transform = TransformationController(); - static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; static const Size _fallbackSlide = Size(1600, 900); @@ -125,15 +118,23 @@ class _PenSlideScreenState extends State { } Future _initPenConfig() async { - final controller = await PenConfigController.load(); + final results = await Future.wait([ + PenConfigController.load(), + PenSlotsController.load(), + ]); + final config = results[0] as PenConfigController; + final slots = results[1] as PenSlotsController; if (!mounted) { - controller.dispose(); + config.dispose(); + slots.dispose(); return; } - controller.addListener(_onPenConfigChanged); + config.addListener(_onPenConfigChanged); + slots.addListener(_onPenSlotsChanged); setState(() { - _penConfig = controller; - _allowFingerDrawing = controller.value.fingerDrawing; + _penConfig = config; + _penSlots = slots; + _allowFingerDrawing = config.value.fingerDrawing; }); } @@ -141,10 +142,16 @@ class _PenSlideScreenState extends State { if (mounted) setState(() {}); } + void _onPenSlotsChanged() { + if (mounted) setState(() {}); + } + @override void dispose() { _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); + _penSlots?.removeListener(_onPenSlotsChanged); + _penSlots?.dispose(); _transform.dispose(); super.dispose(); } @@ -300,7 +307,7 @@ class _PenSlideScreenState extends State { double get _strokeWidth => _tool == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) - : (_penConfig?.value.penWidth ?? _penWidthFraction); + : (_penSlots?.active.width ?? 0.006); // ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ── @@ -465,17 +472,19 @@ class _PenSlideScreenState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // OneNote-style: each pen is its own slot with remembered color. - for (final b in kPenToolBrushes) + // OneNote-style: each pen slot restores brush + color + thickness. + for (final slot in _penSlots?.slots ?? kDefaultPenSlots()) PenSlotButton( - kind: b, - selected: _tool == EditorToolKind.brush && _penBrush == b, - color: _brushColors[b] ?? Colors.black, - tooltip: brushLabelEn(b), - onPressed: () => setState(() { - _penBrush = b; - _tool = EditorToolKind.brush; - }), + kind: slot.brush, + selected: _tool == EditorToolKind.brush && + (_penSlots?.activeId ?? 'slot_0') == slot.id, + color: slot.color, + widthHint: slot.width, + tooltip: brushLabelEn(slot.brush), + onPressed: () { + _penSlots?.select(slot.id); + setState(() => _tool = EditorToolKind.brush); + }, ), ToolButton( icon: Icons.brush_outlined, @@ -530,6 +539,10 @@ class _PenSlideScreenState extends State { ), PaletteDivider(cs: cs), for (final c in _palette) _colorDot(c, cs), + ThicknessPickerButton( + width: _penSlots?.active.width ?? 0.006, + onChanged: (w) => _penSlots?.setActiveWidth(w), + ), PaletteDivider(cs: cs), ToolButton( icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, @@ -558,13 +571,17 @@ class _PenSlideScreenState extends State { _tool != EditorToolKind.eraser && _tool != EditorToolKind.select; return GestureDetector( - onTap: () => setState(() { + onTap: () { if (_tool == EditorToolKind.eraser || _tool == EditorToolKind.select) { - _tool = EditorToolKind.brush; + setState(() => _tool = EditorToolKind.brush); } - _brushColors[_activeColorBrush] = c; - }), + if (_tool == EditorToolKind.highlighter) { + setState(() => _highlighterColor = c); + } else { + _penSlots?.setActiveColor(c); + } + }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.symmetric(horizontal: 3), diff --git a/lib/editor/canvas/sticky_note_overlay.dart b/lib/editor/canvas/sticky_note_overlay.dart index 91b3db9..cb15db2 100644 --- a/lib/editor/canvas/sticky_note_overlay.dart +++ b/lib/editor/canvas/sticky_note_overlay.dart @@ -1,11 +1,10 @@ // lib/editor/canvas/sticky_note_overlay.dart // -// Paper-sticky UX for PDF scratch links: a floating card on the viewer that -// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent -// editor's brush/color/tool so there is one toolbar mental model (OneNote-like). +// Page-anchored paper sticky: sized/positioned by the parent in page space, +// shares the editor brush/color/tool, locks inner pan/zoom so writing feels +// like drawing on the sticky surface itself. import 'dart:async'; -import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; @@ -24,7 +23,7 @@ import 'pen_stroke.dart'; /// Default world size for a fresh sticky scratchpad (absolute px). const Size kStickyWorldSize = Size(1200, 900); -/// Floating sticky-note card: write → autosave into [repo] under [link.id]. +/// Floating sticky-note card glued to a PDF page (parent supplies pixel size). class StickyNoteOverlay extends StatefulWidget { const StickyNoteOverlay({ super.key, @@ -32,9 +31,12 @@ class StickyNoteOverlay extends StatefulWidget { required this.repo, required this.onClose, required this.onDelete, + required this.onDragPx, + required this.onResizePx, this.brush = BrushKind.ballpoint, this.color = const Color(0xFF1A1A1A), this.tool = EditorToolKind.brush, + this.strokeWidth = 0.008, this.allowFingerDrawing = false, }); @@ -43,10 +45,16 @@ class StickyNoteOverlay extends StatefulWidget { final VoidCallback onClose; final VoidCallback onDelete; - /// Shared from the parent PDF toolbar (no mini duplicate palette). + /// Header drag delta in viewer/page pixels. + final void Function(double dx, double dy) onDragPx; + + /// Corner resize delta in viewer/page pixels. + final void Function(double dx, double dy) onResizePx; + final BrushKind brush; final Color color; final EditorToolKind tool; + final double strokeWidth; final bool allowFingerDrawing; @override @@ -62,10 +70,6 @@ class _StickyNoteOverlayState extends State { Timer? _saveTimer; bool _dirty = false; - /// On-screen card size (user-resizable). World canvas stays [_world]. - double _cardW = 300; - double _cardH = 360; - @override void initState() { super.initState(); @@ -73,10 +77,6 @@ class _StickyNoteOverlayState extends State { if (pad != null) { _world = Size(pad.canvasWidth, pad.canvasHeight); _strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList(); - // Prefer a card that roughly matches aspect of the world, clamped. - final aspect = _world.width / math.max(_world.height, 1); - _cardW = (280.0 * aspect).clamp(220.0, 520.0); - _cardH = (_cardW / aspect + 40).clamp(260.0, 640.0); } } @@ -84,15 +84,7 @@ class _StickyNoteOverlayState extends State { void dispose() { _saveTimer?.cancel(); if (_dirty) { - widget.repo.scheduleScratchpadSave( - widget.link.id, - SidecarScratchpad( - canvasWidth: _world.width, - canvasHeight: _world.height, - strokes: List.of(_strokes), - ), - ); - widget.repo.flush(); + _persist(flush: true); } _transform.dispose(); super.dispose(); @@ -101,11 +93,11 @@ class _StickyNoteOverlayState extends State { void _scheduleSave() { _dirty = true; _saveTimer?.cancel(); - _saveTimer = Timer(const Duration(milliseconds: 600), _saveNow); + _saveTimer = Timer(const Duration(milliseconds: 600), () => _persist()); } - Future _saveNow() async { - if (!_dirty) return; + Future _persist({bool flush = false}) async { + if (!_dirty && !flush) return; widget.repo.scheduleScratchpadSave( widget.link.id, SidecarScratchpad( @@ -115,7 +107,7 @@ class _StickyNoteOverlayState extends State { ), ); _dirty = false; - await widget.repo.flush(); + if (flush) await widget.repo.flush(); } void _onStrokeComplete(PenStroke pen) { @@ -145,7 +137,7 @@ class _StickyNoteOverlayState extends State { Future _close() async { _saveTimer?.cancel(); - await _saveNow(); + await _persist(flush: true); widget.onClose(); } @@ -156,6 +148,7 @@ class _StickyNoteOverlayState extends State { case EditorToolKind.select: return CanvasTool.select; case EditorToolKind.highlighter: + return CanvasTool.highlighter; case EditorToolKind.brush: case EditorToolKind.shape: case EditorToolKind.text: @@ -168,13 +161,6 @@ class _StickyNoteOverlayState extends State { ? BrushKind.highlighter : widget.brush; - void _onResizeDrag(DragUpdateDetails d) { - setState(() { - _cardW = (_cardW + d.delta.dx).clamp(220.0, 640.0); - _cardH = (_cardH + d.delta.dy).clamp(240.0, 720.0); - }); - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -182,99 +168,121 @@ class _StickyNoteOverlayState extends State { elevation: 8, borderRadius: BorderRadius.circular(4), color: const Color(0xFFFFF8E1), - child: SizedBox( - width: _cardW, - height: _cardH, - child: Stack( - children: [ - Column( - children: [ - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 4), - decoration: const BoxDecoration( - color: Color(0xFFFFE082), - borderRadius: BorderRadius.vertical(top: Radius.circular(4)), - ), - child: Row( - children: [ - const Icon(Icons.sticky_note_2, size: 18), - const SizedBox(width: 6), - const Expanded( - child: Text( - '便利贴', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - Text( - '用顶栏笔/色', - style: TextStyle( - fontSize: 11, - color: cs.onSurface.withValues(alpha: 0.55), - ), - ), - IconButton( - tooltip: '删除', - icon: const Icon(Icons.delete_outline, size: 18), - visualDensity: VisualDensity.compact, - onPressed: () async { - await _saveNow(); - widget.onDelete(); - }, - ), - IconButton( - tooltip: '收起', - icon: const Icon(Icons.close, size: 18), - visualDensity: VisualDensity.compact, - onPressed: _close, - ), - ], - ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Column( + children: [ + _StickyHeader( + onDragDelta: widget.onDragPx, + onClose: _close, + onDelete: () async { + await _persist(flush: true); + widget.onDelete(); + }, + cs: cs, + ), + Expanded( + child: PenCanvas( + pageSize: _world, + strokes: penStrokesFromInk(_strokes, _world), + transformationController: _transform, + tool: _canvasTool, + brush: _canvasBrush, + color: widget.color, + strokeWidth: widget.strokeWidth, + eraserRadius: kDefaultEraserRadius, + allowFingerDrawing: widget.allowFingerDrawing, + scaleEnabled: false, + panEnabled: false, + minScale: 1.0, + maxScale: 1.0, + onStrokeComplete: _onStrokeComplete, + onEraseStroke: _onErase, + pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)), ), - Expanded( - child: ClipRect( - child: PenCanvas( - pageSize: _world, - strokes: penStrokesFromInk(_strokes, _world), - transformationController: _transform, - tool: _canvasTool, - brush: _canvasBrush, - color: widget.color, - strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction, - eraserRadius: kDefaultEraserRadius, - allowFingerDrawing: widget.allowFingerDrawing, - minScale: 0.2, - maxScale: 4.0, - onStrokeComplete: _onStrokeComplete, - onEraseStroke: _onErase, - pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)), - ), - ), - ), - ], - ), - Positioned( - right: 0, - bottom: 0, - child: GestureDetector( - onPanUpdate: _onResizeDrag, - child: MouseRegion( - cursor: SystemMouseCursors.resizeUpLeftDownRight, - child: SizedBox( - width: 28, - height: 28, - child: Icon( - Icons.south_east, - size: 16, - color: cs.onSurface.withValues(alpha: 0.45), - ), + ), + ], + ), + Positioned( + right: 0, + bottom: 0, + child: GestureDetector( + onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy), + child: MouseRegion( + cursor: SystemMouseCursors.resizeUpLeftDownRight, + child: SizedBox( + width: 28, + height: 28, + child: Icon( + Icons.south_east, + size: 16, + color: cs.onSurface.withValues(alpha: 0.45), ), ), ), ), + ), + ], + ), + ); + } +} + +class _StickyHeader extends StatelessWidget { + const _StickyHeader({ + required this.onDragDelta, + required this.onClose, + required this.onDelete, + required this.cs, + }); + + final void Function(double dx, double dy) onDragDelta; + final VoidCallback onClose; + final VoidCallback onDelete; + final ColorScheme cs; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: const BoxDecoration( + color: Color(0xFFFFE082), + borderRadius: BorderRadius.vertical(top: Radius.circular(4)), + ), + child: Row( + children: [ + const Icon(Icons.drag_indicator, size: 18), + const SizedBox(width: 4), + const Expanded( + child: Text( + '便利贴', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + Text( + '拖标题定位 · 角缩放', + style: TextStyle( + fontSize: 10, + color: cs.onSurface.withValues(alpha: 0.5), + ), + ), + IconButton( + tooltip: '删除', + icon: const Icon(Icons.delete_outline, size: 18), + visualDensity: VisualDensity.compact, + onPressed: onDelete, + ), + IconButton( + tooltip: '收起', + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + onPressed: onClose, + ), ], ), ), diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart index 7e1f55b..caf0ac8 100644 --- a/lib/editor/input/pen_config.dart +++ b/lib/editor/input/pen_config.dart @@ -31,7 +31,7 @@ enum PenButtonAction { /// Persisted under SharedPreferences key [PenConfigController.prefsKey]. class PenConfig { const PenConfig({ - this.sideButton = PenButtonAction.select, + this.sideButton = PenButtonAction.selectText, this.eraserEnd = PenButtonAction.eraser, this.pressureGamma = kNaturalPressureGamma, this.palmRejectionMs = 150.0, diff --git a/lib/editor/input/pen_slots.dart b/lib/editor/input/pen_slots.dart new file mode 100644 index 0000000..faa4c77 --- /dev/null +++ b/lib/editor/input/pen_slots.dart @@ -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 toJson() => { + 'id': id, + 'brush': brush.name, + 'color': color.toARGB32(), + 'width': width, + }; + + factory PenSlot.fromJson(Map 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 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 _slots; + String _activeId; + + List 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 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; + final list = map['slots'] as List?; + if (list != null && list.isNotEmpty) { + slots = [ + for (final e in list) + PenSlot.fromJson(e as Map), + ]; + } + 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 _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 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 setActiveColor(Color c) async { + _replaceActive(active.copyWith(color: c)); + notifyListeners(); + await _persist(); + } + + /// Sets the active slot's stroke width (clamped). + Future 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 setActiveBrush(BrushKind b) async { + if (b == BrushKind.highlighter) return; + _replaceActive(active.copyWith(brush: b)); + notifyListeners(); + await _persist(); + } +} diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index 0d012a3..d167645 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -128,12 +128,38 @@ class SidecarRepository { } final file = File('$sourceFilePath$kSidecarSuffix'); final loaded = await SidecarStore.read(file); - final sidecar = loaded ?? + var sidecar = loaded ?? BadnoteSidecar( sourceFile: _basename(sourceFilePath), docType: docType, + pageCount: docType == 'notebook' ? 1 : null, createdAt: DateTime.now().toUtc(), ); + // Standalone notebooks always carry an explicit pageCount (min 1). Older + // sidecars that omit it are normalized in-memory on open. + if (docType == 'notebook' && + (sidecar.pageCount == null || sidecar.pageCount! < 1)) { + sidecar = BadnoteSidecar( + version: sidecar.version, + sourceFile: sidecar.sourceFile, + docType: sidecar.docType, + title: sidecar.title, + pageCount: 1, + rotation: sidecar.rotation, + createdAt: sidecar.createdAt, + updatedAt: sidecar.updatedAt, + strokes: sidecar.strokes, + highlights: sidecar.highlights, + texts: sidecar.texts, + bookmarks: sidecar.bookmarks, + scratchLinks: sidecar.scratchLinks, + legacyAnnotations: sidecar.legacyAnnotations, + ocrText: sidecar.ocrText, + pageText: sidecar.pageText, + legacyId: sidecar.legacyId, + background: sidecar.background, + ); + } final repo = SidecarRepository._( sourceFilePath: sourceFilePath, sidecar: sidecar, @@ -220,6 +246,14 @@ class SidecarRepository { _replace(strokes: next); } + /// Replace the standalone-notebook page count and schedule a save. No-op if + /// unchanged. [count] is clamped to at least 1. + void schedulePageCountSave(int count) { + final next = count < 1 ? 1 : count; + if (_sidecar.pageCount == next) return; + _replace(pageCount: next); + } + /// Replace the highlight rects for [pageIndex] and schedule a save. void scheduleHighlightSave(int pageIndex, List highlights) { final next = Map>.from(_sidecar.highlights); @@ -345,6 +379,7 @@ class SidecarRepository { /// synchronously here so a later edit can't corrupt an in-flight write. void _replace({ String? title, + int? pageCount, Map>? strokes, Map>? highlights, Map>? texts, @@ -362,7 +397,7 @@ class SidecarRepository { sourceFile: _sidecar.sourceFile, docType: _sidecar.docType, title: title ?? _sidecar.title, - pageCount: _sidecar.pageCount, + pageCount: pageCount ?? _sidecar.pageCount, rotation: _sidecar.rotation, createdAt: _sidecar.createdAt, updatedAt: DateTime.now().toUtc(), diff --git a/lib/main.dart b/lib/main.dart index d331dbd..80c792f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'editor/pdf/pen_capture_region.dart'; import 'editor/persistence/sidecar_flush_observer.dart'; import 'theme/app_theme.dart'; import 'diagnostics/badnote_log.dart'; @@ -18,7 +19,9 @@ import 'services/webdav_sync_service.dart'; import 'storage/sqlite_to_sidecar_migrator.dart'; Future main() async { - WidgetsFlutterBinding.ensureInitialized(); + // Kind-gated PDF pen capture MUST install before runApp — without it + // PenCaptureRegion.currentPointerKind stays null and stylus ink never hits. + PenCaptureBinding.ensureInitialized(); // pdfrx native engine init (required before any PdfViewer is built). pdfrxFlutterInitialize(); diff --git a/lib/models/scratch_link.dart b/lib/models/scratch_link.dart index 538c57a..aba4046 100644 --- a/lib/models/scratch_link.dart +++ b/lib/models/scratch_link.dart @@ -2,11 +2,8 @@ // // A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized // position (nx, ny in [0,1]) on a specific page of a document. Tapping the -// anchor opens a split view whose right pane is an infinite freehand scratchpad -// that BELONGS TO THIS ANCHOR (keyed by [id]). -// -// Plain immutable value class (no freezed codegen) so it compiles without a -// build_runner step. Equality is by value so anchors can be diffed in lists. +// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by +// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page. import 'package:flutter/foundation.dart'; @@ -18,6 +15,8 @@ class ScratchLink { required this.pageIndex, required this.nx, required this.ny, + this.nw = 0.42, + this.nh = 0.36, }); /// Stable anchor id (uuid). Doubles as the scratchpad storage key so each @@ -30,18 +29,26 @@ class ScratchLink { /// 0-based page the anchor sits on. final int pageIndex; - /// Normalized horizontal position on the page, in [0, 1]. + /// Normalized horizontal position on the page, in [0, 1] (top-left of card). final double nx; - /// Normalized vertical position on the page, in [0, 1]. + /// Normalized vertical position on the page, in [0, 1] (top-left of card). final double ny; + /// Expanded card width as a fraction of page width (clamped on write). + final double nw; + + /// Expanded card height as a fraction of page height. + final double nh; + ScratchLink copyWith({ String? id, String? documentId, int? pageIndex, double? nx, double? ny, + double? nw, + double? nh, }) => ScratchLink( id: id ?? this.id, @@ -49,6 +56,8 @@ class ScratchLink { pageIndex: pageIndex ?? this.pageIndex, nx: nx ?? this.nx, ny: ny ?? this.ny, + nw: nw ?? this.nw, + nh: nh ?? this.nh, ); Map toJson() => { @@ -57,6 +66,8 @@ class ScratchLink { 'pageIndex': pageIndex, 'nx': nx, 'ny': ny, + 'nw': nw, + 'nh': nh, }; factory ScratchLink.fromJson(Map json) => ScratchLink( @@ -65,6 +76,8 @@ class ScratchLink { pageIndex: (json['pageIndex'] as num).toInt(), nx: (json['nx'] as num).toDouble(), ny: (json['ny'] as num).toDouble(), + nw: (json['nw'] as num?)?.toDouble() ?? 0.42, + nh: (json['nh'] as num?)?.toDouble() ?? 0.36, ); @override @@ -76,13 +89,15 @@ class ScratchLink { documentId == other.documentId && pageIndex == other.pageIndex && nx == other.nx && - ny == other.ny; + ny == other.ny && + nw == other.nw && + nh == other.nh; @override - int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny); + int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh); @override String toString() => 'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, ' - 'nx: $nx, ny: $ny)'; + 'nx: $nx, ny: $ny, nw: $nw, nh: $nh)'; } diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 7bfeb15..630e1d8 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -265,6 +265,7 @@ class VaultService { BadnoteSidecar( docType: 'notebook', title: pageTitle, + pageCount: 1, createdAt: now, updatedAt: now, ), @@ -311,6 +312,7 @@ class VaultService { BadnoteSidecar( docType: 'notebook', title: title, + pageCount: 1, createdAt: now, updatedAt: now, ), @@ -414,6 +416,7 @@ class VaultService { final sidecar = BadnoteSidecar( docType: 'notebook', title: trimmed.isEmpty ? null : trimmed, + pageCount: 1, createdAt: now, updatedAt: now, ); diff --git a/test/pen_brush_color_memory_test.dart b/test/pen_brush_color_memory_test.dart index 7675751..6f03bad 100644 --- a/test/pen_brush_color_memory_test.dart +++ b/test/pen_brush_color_memory_test.dart @@ -1,11 +1,11 @@ // test/pen_brush_color_memory_test.dart // -// Pins rnote-style per-brush color memory in the note editor: each brush -// remembers its OWN color, selecting a brush restores that brush's color (the -// PenCanvas receives it), and picking a color updates ONLY the active brush's -// entry — switching back to a different brush restores the other color. +// Pins OneNote-style independent pen slots in the note editor: each slot +// remembers its OWN brush + color + width; selecting a slot restores that +// slot's color (the PenCanvas receives it), and picking a color updates ONLY +// the active slot — switching back to a different slot restores the other color. // -// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots) +// Drives the real PenNoteScreen toolbar (PenSlotButton + color dots) // and reads PenCanvas.color to assert the active drawing color. import 'package:flutter/material.dart'; @@ -15,6 +15,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:badnote/editor/canvas/pen_canvas.dart'; import 'package:badnote/editor/canvas/pen_note_screen.dart'; +import 'package:badnote/editor/canvas/pen_palette_widgets.dart'; +import 'package:badnote/editor/engine/brush.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -22,18 +24,17 @@ void main() { Color canvasColor(WidgetTester tester) => tester.widget(find.byType(PenCanvas)).color; - /// Pick the brush named [label] from the BrushPickerButton popup menu. - Future selectBrush(WidgetTester tester, String label) async { - // The brush picker carries the 'Brush' tooltip. - await tester.tap(find.byTooltip('Brush')); - await tester.pumpAndSettle(); - await tester.tap(find.text(label).last); + /// Activate the [PenSlotButton] for [kind] via its onPressed (avoids hit-test + /// collisions with the floating back button over the left of the palette). + Future selectSlot(WidgetTester tester, BrushKind kind) async { + final finder = find.byWidgetPredicate( + (w) => w is PenSlotButton && w.kind == kind, + ); + tester.widget(finder).onPressed(); await tester.pumpAndSettle(); } - /// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an - /// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture - /// detector ancestor so the onTap fires. + /// Tap the toolbar color dot whose swatch is exactly [c]. Future tapColorDot(WidgetTester tester, Color c) async { final swatch = find.byWidgetPredicate((w) => w is AnimatedContainer && @@ -41,40 +42,49 @@ void main() { (w.decoration as BoxDecoration).color == c && (w.decoration as BoxDecoration).shape == BoxShape.circle); final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector)); - await tester.tap(gd.first); + await tester.ensureVisible(gd.first); + await tester.pumpAndSettle(); + await tester.tap(gd.first, warnIfMissed: false); await tester.pump(); } - testWidgets('each brush remembers its own color; switching restores it', + testWidgets('each pen slot remembers its own color; switching restores it', (tester) async { SharedPreferences.setMockInitialValues({}); + // Wide surface so the palette + color dots aren't crushed under chrome. + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const ProviderScope( child: MaterialApp(home: PenNoteScreen()), )); - await tester.pump(); // let PenConfig load + await tester.pump(); // let PenConfig + PenSlots load + await tester.pump(); - // Defaults from _brushColors: fountain pen = black, ballpoint = blue. + // Defaults: fountain pen = black, ballpoint = blue. expect(canvasColor(tester), Colors.black, reason: 'fountain pen starts black'); - // Switch to the ballpoint brush → its remembered color (blue) becomes active. - await selectBrush(tester, 'Ballpoint'); + // Switch to the ballpoint slot → its remembered color (blue) becomes active. + await selectSlot(tester, BrushKind.ballpoint); expect(canvasColor(tester), Colors.blue, reason: 'selecting ballpoint restores ITS remembered color'); - // Change the ACTIVE (ballpoint) brush's color to red via a color dot. - await tapColorDot(tester, Colors.red); - expect(canvasColor(tester), Colors.red, - reason: 'color change applies to the active brush'); + // Change the ACTIVE (ballpoint) slot's color via a palette color dot. + const paletteRed = Color(0xFFC62828); + await tapColorDot(tester, paletteRed); + await tester.pump(); + expect(canvasColor(tester), paletteRed, + reason: 'color change applies to the active slot'); // Switch back to the fountain pen → its color is still black (unchanged). - await selectBrush(tester, 'Fountain pen'); + await selectSlot(tester, BrushKind.fountainPen); expect(canvasColor(tester), Colors.black, reason: 'fountain pen color was NOT affected by changing ballpoint'); // Back to ballpoint → it remembers the red we set. - await selectBrush(tester, 'Ballpoint'); - expect(canvasColor(tester), Colors.red, + await selectSlot(tester, BrushKind.ballpoint); + expect(canvasColor(tester), paletteRed, reason: 'ballpoint remembers its own updated color'); }); } diff --git a/test/pen_slots_test.dart b/test/pen_slots_test.dart new file mode 100644 index 0000000..7b284cf --- /dev/null +++ b/test/pen_slots_test.dart @@ -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(); + }); + }); +} diff --git a/test/sidecar_repository_test.dart b/test/sidecar_repository_test.dart index 566b3d3..bdeb356 100644 --- a/test/sidecar_repository_test.dart +++ b/test/sidecar_repository_test.dart @@ -122,6 +122,26 @@ void main() { reopened.dispose(); }); + test('schedulePageCountSave persists and restores pageCount', () async { + final repo = await SidecarRepository.open(src, debounce: _fast); + repo.schedulePageCountSave(5); + await repo.flush(); + expect(repo.sidecar.pageCount, 5); + repo.dispose(); + + final reopened = await SidecarRepository.open(src, debounce: _fast); + expect(reopened.sidecar.pageCount, 5); + reopened.dispose(); + }); + + test('notebook open normalizes missing pageCount to 1', () async { + final noteSrc = '${tmpDir.path}/notebook'; + final repo = + await SidecarRepository.open(noteSrc, docType: 'notebook', debounce: _fast); + expect(repo.sidecar.pageCount, 1); + repo.dispose(); + }); + test('removing a highlight persists (un-highlight)', () async { final repo = await SidecarRepository.open(src, debounce: _fast); repo.scheduleHighlightSave(0, const [ diff --git a/test/vault_service_test.dart b/test/vault_service_test.dart index ccdbf53..5c18d27 100644 --- a/test/vault_service_test.dart +++ b/test/vault_service_test.dart @@ -237,6 +237,7 @@ void main() { expect(loaded, isNotNull); expect(loaded!.title, 'My Algebra Notes'); expect(loaded.docType, 'notebook'); + expect(loaded.pageCount, 1); // The folder holds only the sidecar (and possibly its .bak/.tmp), never // an importable source file. final files = Directory(expectedFolder)