feat(editor): undo/redo, thumbnails, pen settings
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:
2026-06-21 23:56:38 +08:00
parent 28b7af7e72
commit e4a94d00c0
7 changed files with 1206 additions and 7 deletions

View File

@@ -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,
),
),
),