529 lines
17 KiB
Dart
529 lines
17 KiB
Dart
|
|
import 'dart:io';
|
||
|
|
import 'dart:math';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:path/path.dart' as p;
|
||
|
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||
|
|
|
||
|
|
import '../models/ink_stroke.dart';
|
||
|
|
import '../models/pen_tool.dart';
|
||
|
|
import '../models/pressure_curve.dart';
|
||
|
|
import '../services/undo_manager.dart';
|
||
|
|
import '../utils/stroke_stabilizer.dart';
|
||
|
|
import '../widgets/annotation_toolbar.dart';
|
||
|
|
import '../widgets/ink_canvas.dart';
|
||
|
|
|
||
|
|
/// Per-slide annotation state. The [UndoManager] is the single source of
|
||
|
|
/// truth for a slide's strokes; [strokes] reflects its current contents so
|
||
|
|
/// the live canvas and the PDF export always render what was actually drawn.
|
||
|
|
class _SlideAnnotations {
|
||
|
|
final UndoManager undoManager = UndoManager();
|
||
|
|
List<InkStroke> get strokes => undoManager.currentStrokes;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Screen that displays PPTX slides with an ink annotation overlay.
|
||
|
|
///
|
||
|
|
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
|
||
|
|
/// sits on top of each slide so the user can annotate freely. Annotations are
|
||
|
|
/// stored per-slide and can be exported as a PDF.
|
||
|
|
class PptAnnotatorScreen extends StatefulWidget {
|
||
|
|
final String filePath;
|
||
|
|
final List<String> slideImagePaths;
|
||
|
|
final String? extractedText;
|
||
|
|
|
||
|
|
const PptAnnotatorScreen({
|
||
|
|
super.key,
|
||
|
|
required this.filePath,
|
||
|
|
required this.slideImagePaths,
|
||
|
|
this.extractedText,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
|
||
|
|
late final PageController _pageController;
|
||
|
|
late final Map<int, _SlideAnnotations> _annotations;
|
||
|
|
int _currentPage = 0;
|
||
|
|
bool _isDrawing = false;
|
||
|
|
bool _showTextPanel = false;
|
||
|
|
// Set to true once the unsaved-annotations warning SnackBar has been shown.
|
||
|
|
bool _hasShownUnsavedWarning = false;
|
||
|
|
|
||
|
|
// Toolbar state
|
||
|
|
PenTool _currentTool = PenTool.pen;
|
||
|
|
Color _currentColor = Colors.black;
|
||
|
|
double _currentStrokeWidth = 2.0;
|
||
|
|
bool _filled = false;
|
||
|
|
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||
|
|
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||
|
|
|
||
|
|
// Derived
|
||
|
|
late final String _fileName;
|
||
|
|
late final int _slideCount;
|
||
|
|
late final String _extractedText;
|
||
|
|
|
||
|
|
PressureCurve get _pressureCurve {
|
||
|
|
switch (_pressureCurveType) {
|
||
|
|
case PressureCurveType.linear:
|
||
|
|
return PressureCurve.linear;
|
||
|
|
case PressureCurveType.soft:
|
||
|
|
return PressureCurve.soft;
|
||
|
|
case PressureCurveType.hard:
|
||
|
|
return PressureCurve.hard;
|
||
|
|
case PressureCurveType.custom:
|
||
|
|
return const PressureCurve(type: PressureCurveType.custom);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
UndoManager get _currentUndoManager =>
|
||
|
|
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_fileName = p.basename(widget.filePath);
|
||
|
|
_slideCount = widget.slideImagePaths.length;
|
||
|
|
_extractedText = widget.extractedText ?? '';
|
||
|
|
|
||
|
|
_pageController = PageController();
|
||
|
|
_annotations = {};
|
||
|
|
for (var i = 0; i < _slideCount; i++) {
|
||
|
|
_annotations[i] = _SlideAnnotations();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void dispose() {
|
||
|
|
_pageController.dispose();
|
||
|
|
super.dispose();
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Drawing callbacks --
|
||
|
|
|
||
|
|
void _onStrokeComplete(InkStroke stroke) {
|
||
|
|
setState(() {
|
||
|
|
_currentUndoManager.addStroke(stroke);
|
||
|
|
});
|
||
|
|
// Warn once per session that PPT annotations are not auto-saved.
|
||
|
|
if (!_hasShownUnsavedWarning) {
|
||
|
|
_hasShownUnsavedWarning = true;
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
||
|
|
const SnackBar(
|
||
|
|
content: Text(
|
||
|
|
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
|
||
|
|
),
|
||
|
|
duration: Duration(seconds: 5),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||
|
|
setState(() {
|
||
|
|
final original = _currentUndoManager.currentStrokes
|
||
|
|
.where((s) => s.id == strokeId)
|
||
|
|
.firstOrNull;
|
||
|
|
if (original != null) {
|
||
|
|
_currentUndoManager.removeStroke(original, replacements: replacements);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Export --
|
||
|
|
|
||
|
|
Future<void> _exportPdf() async {
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
|
||
|
|
|
||
|
|
try {
|
||
|
|
final bytes = await _buildPdfBytes();
|
||
|
|
if (!mounted) return;
|
||
|
|
|
||
|
|
final dir = await _getExportDir();
|
||
|
|
final baseName = p.basenameWithoutExtension(_fileName);
|
||
|
|
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
|
||
|
|
await File(outPath).writeAsBytes(bytes);
|
||
|
|
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
||
|
|
} catch (e) {
|
||
|
|
if (!mounted) return;
|
||
|
|
ScaffoldMessenger.of(
|
||
|
|
context,
|
||
|
|
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Directory> _getExportDir() async {
|
||
|
|
try {
|
||
|
|
final home = Platform.environment['HOME'];
|
||
|
|
if (home != null) {
|
||
|
|
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
|
||
|
|
if (!await dir.exists()) {
|
||
|
|
await dir.create(recursive: true);
|
||
|
|
}
|
||
|
|
return dir;
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
return Directory.current;
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Uint8List> _buildPdfBytes() async {
|
||
|
|
final doc = PdfDocument();
|
||
|
|
doc.pageSettings.margins.all = 0;
|
||
|
|
|
||
|
|
for (var i = 0; i < _slideCount; i++) {
|
||
|
|
final page = doc.pages.add();
|
||
|
|
final pageSize = page.getClientSize();
|
||
|
|
|
||
|
|
// Draw slide image
|
||
|
|
final imgPath = widget.slideImagePaths[i];
|
||
|
|
try {
|
||
|
|
final imgBytes = await File(imgPath).readAsBytes();
|
||
|
|
final bitmap = PdfBitmap(imgBytes);
|
||
|
|
|
||
|
|
final imgW = bitmap.width.toDouble();
|
||
|
|
final imgH = bitmap.height.toDouble();
|
||
|
|
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
|
||
|
|
final drawW = imgW * scale;
|
||
|
|
final drawH = imgH * scale;
|
||
|
|
final offX = (pageSize.width - drawW) / 2;
|
||
|
|
final offY = (pageSize.height - drawH) / 2;
|
||
|
|
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
|
||
|
|
|
||
|
|
page.graphics.drawImage(bitmap, imgRect);
|
||
|
|
|
||
|
|
// Draw ink strokes
|
||
|
|
final annots = _annotations[i];
|
||
|
|
if (annots != null && annots.strokes.isNotEmpty) {
|
||
|
|
// KNOWN LIMITATION: strokes are captured in the live viewer's
|
||
|
|
// full-fill pixel space (the InkCanvas is Positioned.fill over the
|
||
|
|
// whole slide area, while the slide image is BoxFit.contain inside
|
||
|
|
// it). The scale below is derived from the PDF page layout, not the
|
||
|
|
// live widget size, so exported ink can be misaligned/scaled wrong.
|
||
|
|
// A correct fix normalizes strokes to [0,1] of the *rendered image
|
||
|
|
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
|
||
|
|
// to the PDF draw rect here. Requires on-device visual verification.
|
||
|
|
final imgAspect = imgW / imgH;
|
||
|
|
final pageAspect = pageSize.width / pageSize.height;
|
||
|
|
double widgetW, widgetH;
|
||
|
|
if (imgAspect > pageAspect) {
|
||
|
|
widgetW = pageSize.width;
|
||
|
|
widgetH = pageSize.width / imgAspect;
|
||
|
|
} else {
|
||
|
|
widgetH = pageSize.height;
|
||
|
|
widgetW = pageSize.height * imgAspect;
|
||
|
|
}
|
||
|
|
final scaleX = drawW / widgetW;
|
||
|
|
final scaleY = drawH / widgetH;
|
||
|
|
|
||
|
|
for (final stroke in annots.strokes) {
|
||
|
|
if (stroke.tool == PenTool.eraser) continue;
|
||
|
|
if (stroke.points.length < 2) continue;
|
||
|
|
|
||
|
|
final r = (stroke.color >> 16) & 0xFF;
|
||
|
|
final g = (stroke.color >> 8) & 0xFF;
|
||
|
|
final b = stroke.color & 0xFF;
|
||
|
|
final pdfColor = PdfColor(r, g, b);
|
||
|
|
|
||
|
|
final path = PdfPath();
|
||
|
|
path.startFigure();
|
||
|
|
for (var j = 0; j < stroke.points.length - 1; j++) {
|
||
|
|
final pt1 = stroke.points[j];
|
||
|
|
final pt2 = stroke.points[j + 1];
|
||
|
|
path.addLine(
|
||
|
|
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
|
||
|
|
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
page.graphics.drawPath(
|
||
|
|
path,
|
||
|
|
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
page.graphics.drawRectangle(
|
||
|
|
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
|
||
|
|
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
final bytes = await doc.save();
|
||
|
|
doc.dispose();
|
||
|
|
return Uint8List.fromList(bytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- UI --
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
// No slides to annotate: show an empty state and skip the toolbar, which
|
||
|
|
// would otherwise dereference a non-existent slide's annotation state.
|
||
|
|
if (_slideCount == 0) {
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||
|
|
),
|
||
|
|
body: const Center(child: Text('No slides to display')),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
appBar: AppBar(
|
||
|
|
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||
|
|
actions: [
|
||
|
|
if (_extractedText.isNotEmpty)
|
||
|
|
IconButton(
|
||
|
|
icon: Icon(
|
||
|
|
_showTextPanel
|
||
|
|
? Icons.text_snippet
|
||
|
|
: Icons.text_snippet_outlined,
|
||
|
|
),
|
||
|
|
tooltip: 'Toggle extracted text',
|
||
|
|
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.picture_as_pdf),
|
||
|
|
tooltip: 'Export as PDF',
|
||
|
|
onPressed: _exportPdf,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
body: Column(
|
||
|
|
children: [
|
||
|
|
AnnotationToolbar(
|
||
|
|
currentTool: _currentTool,
|
||
|
|
currentColor: _currentColor,
|
||
|
|
currentStrokeWidth: _currentStrokeWidth,
|
||
|
|
filled: _filled,
|
||
|
|
pressureCurveType: _pressureCurveType,
|
||
|
|
stabilizationLevel: _stabilizationLevel,
|
||
|
|
canUndo: _currentUndoManager.canUndo,
|
||
|
|
canRedo: _currentUndoManager.canRedo,
|
||
|
|
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||
|
|
onColorChanged: (color) => setState(() => _currentColor = color),
|
||
|
|
onStrokeWidthChanged: (w) =>
|
||
|
|
setState(() => _currentStrokeWidth = w),
|
||
|
|
onFilledChanged: (f) => setState(() => _filled = f),
|
||
|
|
onPressureCurveChanged: (v) =>
|
||
|
|
setState(() => _pressureCurveType = v),
|
||
|
|
onStabilizationChanged: (v) =>
|
||
|
|
setState(() => _stabilizationLevel = v),
|
||
|
|
onUndo: _undo,
|
||
|
|
onRedo: _redo,
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Expanded(child: _buildSlideViewer()),
|
||
|
|
if (_showTextPanel) _buildTextPanel(),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
_buildPageIndicator(),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildSlideViewer() {
|
||
|
|
if (_slideCount == 0) {
|
||
|
|
return const Center(child: Text('No slides to display'));
|
||
|
|
}
|
||
|
|
|
||
|
|
return Listener(
|
||
|
|
onPointerDown: (_) => setState(() => _isDrawing = true),
|
||
|
|
onPointerUp: (_) => setState(() => _isDrawing = false),
|
||
|
|
child: PageView.builder(
|
||
|
|
controller: _pageController,
|
||
|
|
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
|
||
|
|
itemCount: _slideCount,
|
||
|
|
onPageChanged: (page) => setState(() => _currentPage = page),
|
||
|
|
itemBuilder: (context, index) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.all(8),
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
// Slide image (background)
|
||
|
|
Positioned.fill(
|
||
|
|
child: Image.file(
|
||
|
|
File(widget.slideImagePaths[index]),
|
||
|
|
fit: BoxFit.contain,
|
||
|
|
errorBuilder: (context, error, stackTrace) => Container(
|
||
|
|
color: Colors.grey.shade200,
|
||
|
|
child: Center(
|
||
|
|
child: Text(
|
||
|
|
'Slide ${index + 1}',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 24,
|
||
|
|
color: Colors.grey.shade500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
// Ink annotation overlay (foreground)
|
||
|
|
Positioned.fill(
|
||
|
|
child: InkCanvas(
|
||
|
|
strokes: _annotations[index]?.strokes ?? [],
|
||
|
|
onStrokeComplete: _onStrokeComplete,
|
||
|
|
onErase: _onErase,
|
||
|
|
tool: _currentTool,
|
||
|
|
color: _currentColor,
|
||
|
|
strokeWidth: _currentStrokeWidth,
|
||
|
|
pressureCurve: _pressureCurve,
|
||
|
|
stabilizationLevel: _stabilizationLevel,
|
||
|
|
filled: _filled,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildPageIndicator() {
|
||
|
|
if (_slideCount == 0) return const SizedBox.shrink();
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||
|
|
color: Theme.of(context).colorScheme.surface,
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
// Previous button — always present for both modes.
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.chevron_left),
|
||
|
|
onPressed: _currentPage > 0
|
||
|
|
? () => _pageController.previousPage(
|
||
|
|
duration: const Duration(milliseconds: 300),
|
||
|
|
curve: Curves.easeInOut,
|
||
|
|
)
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
// Dot row for small decks; compact text counter for large decks.
|
||
|
|
if (_slideCount <= 12)
|
||
|
|
...List.generate(_slideCount, (i) {
|
||
|
|
final isActive = i == _currentPage;
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: () => _pageController.animateToPage(
|
||
|
|
i,
|
||
|
|
duration: const Duration(milliseconds: 300),
|
||
|
|
curve: Curves.easeInOut,
|
||
|
|
),
|
||
|
|
child: Container(
|
||
|
|
width: isActive ? 12 : 8,
|
||
|
|
height: isActive ? 12 : 8,
|
||
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
color: isActive
|
||
|
|
? Theme.of(context).colorScheme.primary
|
||
|
|
: Colors.grey.shade400,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
})
|
||
|
|
else
|
||
|
|
Text(
|
||
|
|
'${_currentPage + 1} / $_slideCount',
|
||
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
|
|
),
|
||
|
|
// Next button — always present for both modes.
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.chevron_right),
|
||
|
|
onPressed: _currentPage < _slideCount - 1
|
||
|
|
? () => _pageController.nextPage(
|
||
|
|
duration: const Duration(milliseconds: 300),
|
||
|
|
curve: Curves.easeInOut,
|
||
|
|
)
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
const Spacer(),
|
||
|
|
// Slide counter is always shown at the trailing end for dot mode;
|
||
|
|
// the compact text above already serves this role for large decks.
|
||
|
|
if (_slideCount <= 12)
|
||
|
|
Text(
|
||
|
|
'${_currentPage + 1} / $_slideCount',
|
||
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _buildTextPanel() {
|
||
|
|
return SizedBox(
|
||
|
|
width: 280,
|
||
|
|
child: Card(
|
||
|
|
margin: const EdgeInsets.all(8),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||
|
|
borderRadius: const BorderRadius.vertical(
|
||
|
|
top: Radius.circular(12),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
const Icon(Icons.text_fields, size: 18),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(
|
||
|
|
'Extracted Text',
|
||
|
|
style: Theme.of(context).textTheme.titleSmall,
|
||
|
|
),
|
||
|
|
const Spacer(),
|
||
|
|
IconButton(
|
||
|
|
icon: const Icon(Icons.close, size: 18),
|
||
|
|
onPressed: () => setState(() => _showTextPanel = false),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: SingleChildScrollView(
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
child: SelectableText(
|
||
|
|
_extractedText,
|
||
|
|
style: Theme.of(context).textTheme.bodySmall,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// -- Dialogs --
|
||
|
|
|
||
|
|
void _undo() {
|
||
|
|
setState(() => _currentUndoManager.undo());
|
||
|
|
}
|
||
|
|
|
||
|
|
void _redo() {
|
||
|
|
setState(() => _currentUndoManager.redo());
|
||
|
|
}
|
||
|
|
}
|