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:
235
lib/editor/ui/thumbnail_grid.dart
Normal file
235
lib/editor/ui/thumbnail_grid.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
// lib/editor/ui/thumbnail_grid.dart
|
||||
//
|
||||
// Drawboard-style page thumbnail grid for BadNote.
|
||||
// Shows lazy GridView of PDF page thumbnails via PdfPageView.
|
||||
// Used as a modal bottom sheet via showPageThumbnailSheet().
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
/// A lazy grid of page thumbnails for a PDF document.
|
||||
///
|
||||
/// [document] — the open PdfDocument.
|
||||
/// [currentPage] — 0-based index of the currently active page.
|
||||
/// [onPageSelected] — called with the 0-based page index when the user taps a thumbnail.
|
||||
class PageThumbnailGrid extends StatefulWidget {
|
||||
const PageThumbnailGrid({
|
||||
super.key,
|
||||
required this.document,
|
||||
required this.currentPage,
|
||||
required this.onPageSelected,
|
||||
});
|
||||
|
||||
final PdfDocument document;
|
||||
final int currentPage;
|
||||
final ValueChanged<int> onPageSelected;
|
||||
|
||||
@override
|
||||
State<PageThumbnailGrid> createState() => _PageThumbnailGridState();
|
||||
}
|
||||
|
||||
class _PageThumbnailGridState extends State<PageThumbnailGrid> {
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
/// Approximate tile height used for the initial jump calculation.
|
||||
/// The grid uses a cross-axis count of 3, so tile width ≈ screenWidth/3.
|
||||
/// We rely on a fixed thumbnail width of ~120 logical pixels so the
|
||||
/// aspect ratio (A4 ≈ 1:√2) gives us roughly 170 px tall per tile.
|
||||
static const double _tileHeightEstimate = 170.0;
|
||||
static const double _crossAxisCount = 3;
|
||||
static const double _thumbnailWidth = 120.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PageThumbnailGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentPage != widget.currentPage) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage());
|
||||
}
|
||||
}
|
||||
|
||||
void _jumpToCurrentPage() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final row = widget.currentPage ~/ _crossAxisCount.toInt();
|
||||
final offset = row * _tileHeightEstimate;
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
_scrollController.jumpTo(offset.clamp(0.0, maxOffset));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pageCount = widget.document.pages.length;
|
||||
return GridView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: _thumbnailWidth + 24,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: _thumbnailWidth / _tileHeightEstimate,
|
||||
),
|
||||
itemCount: pageCount,
|
||||
itemBuilder: (context, index) {
|
||||
return _ThumbnailTile(
|
||||
document: widget.document,
|
||||
pageIndex: index,
|
||||
isSelected: index == widget.currentPage,
|
||||
onTap: () => widget.onPageSelected(index),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThumbnailTile extends StatelessWidget {
|
||||
const _ThumbnailTile({
|
||||
required this.document,
|
||||
required this.pageIndex,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PdfDocument document;
|
||||
final int pageIndex;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PdfPageView(
|
||||
document: document,
|
||||
pageNumber: pageIndex + 1, // PdfPageView is 1-based
|
||||
backgroundColor: colorScheme.surface,
|
||||
decoration: const BoxDecoration(color: Colors.white),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh,
|
||||
child: Text(
|
||||
'${pageIndex + 1}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a Material You modal bottom sheet containing a [PageThumbnailGrid].
|
||||
///
|
||||
/// [document] — the open PdfDocument.
|
||||
/// [currentPage] — 0-based index of the currently active page.
|
||||
/// [onPageSelected] — called with the 0-based page index when the user selects
|
||||
/// a thumbnail; the sheet is automatically dismissed afterwards.
|
||||
Future<void> showPageThumbnailSheet(
|
||||
BuildContext context, {
|
||||
required PdfDocument document,
|
||||
required int currentPage,
|
||||
required ValueChanged<int> onPageSelected,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
// Drag handle
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(sheetContext).colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Header row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Pages',
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Thumbnail grid — fill remaining height
|
||||
Expanded(
|
||||
child: PageThumbnailGrid(
|
||||
document: document,
|
||||
currentPage: currentPage,
|
||||
onPageSelected: (index) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onPageSelected(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user