feat(editor): undo/redo, thumbnails, pen settings
All checks were successful
CI / Windows build (push) Successful in 8m39s
All checks were successful
CI / Windows build (push) Successful in 8m39s
Per the full-refactor plan (P0/P1/P2 modules, all pressure-independent): - engine/undo_stack: generic snapshot undo/redo (per page in the editor) - ui/thumbnail_grid: Drawboard-style lazy thumbnail nav sheet (pdfrx) - input/pen_config + ui/pen_settings_page: configurable side-button / eraser-end action mapping, pressure curve, palm sensitivity, finger drawing, widths (shared_preferences). Button-action mappings persist but consume in the input arbiter later; widths/finger consumed now. Wired into the live editor (undo/redo + grid + settings buttons). 19 new tests.
This commit is contained in:
@@ -10,8 +10,12 @@ import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import '../../services/database_service.dart';
|
||||
import '../engine/stroke_model.dart';
|
||||
import '../engine/undo_stack.dart';
|
||||
import '../input/pen_config.dart';
|
||||
import '../persistence/editor_repository.dart';
|
||||
import '../persistence/save_scheduler.dart';
|
||||
import '../ui/pen_settings_page.dart';
|
||||
import '../ui/thumbnail_grid.dart';
|
||||
import 'pen_canvas.dart';
|
||||
import 'pen_stroke.dart';
|
||||
|
||||
@@ -47,6 +51,22 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||
|
||||
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
||||
/// pre-mutation stroke list is recorded before each commit/erase.
|
||||
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
|
||||
|
||||
UndoStack<List<PenStroke>> _undoFor(int page) =>
|
||||
_undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());
|
||||
|
||||
/// Pen input configuration (widths, finger drawing, button actions).
|
||||
/// Loaded asynchronously in initState; null until ready.
|
||||
///
|
||||
/// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd)
|
||||
/// are persisted via this controller but NOT yet consumed here — they wire
|
||||
/// into the input arbiter in a later step. Only widths and fingerDrawing are
|
||||
/// consumed for now.
|
||||
PenConfigController? _penConfig;
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stable document-id derived from the PDF file path.
|
||||
@@ -95,9 +115,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
super.initState();
|
||||
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||
_initPersistence();
|
||||
_initPenConfig();
|
||||
_open();
|
||||
}
|
||||
|
||||
Future<void> _initPenConfig() async {
|
||||
final controller = await PenConfigController.load();
|
||||
if (!mounted) {
|
||||
controller.dispose();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_penConfig = controller;
|
||||
// Adopt the persisted finger-drawing preference as the initial local
|
||||
// toggle state. The local 🖐 toggle keeps working and stays in sync with
|
||||
// the controller (see _toggleFingerDrawing).
|
||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initPersistence() async {
|
||||
final service = await DatabaseService.getInstance();
|
||||
if (!mounted) return;
|
||||
@@ -174,6 +210,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
_document?.dispose();
|
||||
_transform.dispose();
|
||||
_penConfig?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -181,6 +218,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||||
|
||||
void _commitStroke(PenStroke stroke) {
|
||||
// Snapshot-before-change: record the pre-mutation page state for undo.
|
||||
_undoFor(_pageIndex).record(List<PenStroke>.of(_currentStrokes));
|
||||
setState(() {
|
||||
// Replace with a NEW list so StaticInkPainter sees a fresh identity and
|
||||
// actually repaints (mutating in place would alias the old painter's list
|
||||
@@ -196,8 +235,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
|
||||
void _eraseStroke(int index) {
|
||||
final list = _strokesByPage[_pageIndex];
|
||||
final willMutate = list != null && index >= 0 && index < list.length;
|
||||
if (willMutate) {
|
||||
// Snapshot-before-change: record the pre-mutation page state for undo.
|
||||
_undoFor(_pageIndex).record(List<PenStroke>.of(list));
|
||||
}
|
||||
setState(() {
|
||||
final list = _strokesByPage[_pageIndex];
|
||||
if (list != null && index >= 0 && index < list.length) {
|
||||
final next = List<PenStroke>.of(list)..removeAt(index);
|
||||
_strokesByPage[_pageIndex] = next;
|
||||
@@ -237,6 +281,60 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Undo the last draw/erase on the current page, restoring and persisting
|
||||
/// the previous snapshot.
|
||||
void _performUndo() {
|
||||
final stack = _undoFor(_pageIndex);
|
||||
if (!stack.canUndo) return;
|
||||
final current = List<PenStroke>.of(_currentStrokes);
|
||||
final snapshot = stack.undo(current);
|
||||
if (snapshot == null) return;
|
||||
setState(() {
|
||||
// New list identity so StaticInkPainter repaints.
|
||||
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
||||
});
|
||||
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
|
||||
}
|
||||
|
||||
/// Redo the last undone draw/erase on the current page.
|
||||
void _performRedo() {
|
||||
final stack = _undoFor(_pageIndex);
|
||||
if (!stack.canRedo) return;
|
||||
final snapshot = stack.redo();
|
||||
if (snapshot == null) return;
|
||||
setState(() {
|
||||
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
||||
});
|
||||
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
|
||||
}
|
||||
|
||||
/// Toggle finger-drawing, keeping the local state and the persisted config
|
||||
/// (when loaded) in sync.
|
||||
void _toggleFingerDrawing() {
|
||||
final next = !_allowFingerDrawing;
|
||||
setState(() => _allowFingerDrawing = next);
|
||||
_penConfig?.setFingerDrawing(next);
|
||||
}
|
||||
|
||||
/// Open the page thumbnail grid; tapping a thumbnail navigates to that page.
|
||||
void _openThumbnails() {
|
||||
final doc = _document;
|
||||
if (doc == null) return;
|
||||
showPageThumbnailSheet(
|
||||
context,
|
||||
document: doc,
|
||||
currentPage: _pageIndex,
|
||||
onPageSelected: _goToPage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the pen settings sheet (widths, pressure, finger drawing, etc.).
|
||||
void _openPenSettings() {
|
||||
final config = _penConfig;
|
||||
if (config == null) return;
|
||||
showPenSettingsSheet(context, config);
|
||||
}
|
||||
|
||||
/// Centre [pageSize] within [viewport] via the shared transform.
|
||||
void _centerPage(Size viewport, Size pageSize) {
|
||||
final tx = (viewport.width - pageSize.width) / 2;
|
||||
@@ -356,8 +454,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
tool: _tool,
|
||||
color: _color,
|
||||
strokeWidth: _tool == CanvasTool.highlighter
|
||||
? _highlighterWidthFraction
|
||||
: _penWidthFraction,
|
||||
? (_penConfig?.value.highlighterWidth ??
|
||||
_highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction),
|
||||
allowFingerDrawing: _allowFingerDrawing,
|
||||
onPenDebug: _showPenDebug
|
||||
? (s) => setState(() => _penDebug = s)
|
||||
@@ -410,6 +509,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||
),
|
||||
_Divider(cs: cs),
|
||||
// Undo / redo (per page).
|
||||
_ToolButton(
|
||||
icon: Icons.undo,
|
||||
selected: false,
|
||||
tooltip: 'Undo',
|
||||
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
||||
),
|
||||
_ToolButton(
|
||||
icon: Icons.redo,
|
||||
selected: false,
|
||||
tooltip: 'Redo',
|
||||
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
|
||||
),
|
||||
_Divider(cs: cs),
|
||||
for (final c in _palette) _colorDot(c, cs),
|
||||
_Divider(cs: cs),
|
||||
_ToolButton(
|
||||
@@ -418,8 +531,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
tooltip: _allowFingerDrawing
|
||||
? 'Finger drawing ON'
|
||||
: 'Finger drawing OFF (pen only)',
|
||||
onPressed: () =>
|
||||
setState(() => _allowFingerDrawing = !_allowFingerDrawing),
|
||||
onPressed: _toggleFingerDrawing,
|
||||
),
|
||||
// Page thumbnail grid.
|
||||
_ToolButton(
|
||||
icon: Icons.grid_view,
|
||||
selected: false,
|
||||
tooltip: 'Pages',
|
||||
onPressed: _document != null ? _openThumbnails : null,
|
||||
),
|
||||
// Pen settings.
|
||||
_ToolButton(
|
||||
icon: Icons.settings_outlined,
|
||||
selected: false,
|
||||
tooltip: 'Pen settings',
|
||||
onPressed: _penConfig != null ? _openPenSettings : null,
|
||||
),
|
||||
_ToolButton(
|
||||
icon: Icons.bug_report_outlined,
|
||||
@@ -549,11 +675,19 @@ class _ToolButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final enabled = onPressed != null;
|
||||
final iconColor = !enabled
|
||||
? cs.onSurfaceVariant.withValues(alpha: 0.38)
|
||||
: selected
|
||||
? cs.onSecondaryContainer
|
||||
: cs.onSurfaceVariant;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
@@ -570,7 +704,7 @@ class _ToolButton extends StatelessWidget {
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user