feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s
All checks were successful
CI / Windows build (push) Successful in 14m34s
PPT slides now annotate with the single performant inking engine (PenCanvas) instead of the old ink_canvas, per "all note features on the pen-first canvas". - PenSlideScreen: per-slide normalized strokes over each slide image, prev/next + slider nav, undo/redo, shared M3 palette, and the pressure curve / eraser size+mode / palm rejection from the shared canvas. - slide_export: pure, tested export geometry. Because strokes are now normalized to the page rect, the PDF exporter maps them straight into each slide's draw rect — fixing the old exporter's known ink misalignment (it guessed live-widget size). - Route PPT import + open -> PenSlideScreen; delete the dead old ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for split_view, the last old-canvas screen.) Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269.
This commit is contained in:
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
544
lib/editor/canvas/pen_slide_screen.dart
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
// lib/editor/canvas/pen_slide_screen.dart
|
||||||
|
//
|
||||||
|
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
|
||||||
|
// (PenCanvas) over each slide image, with per-slide normalized strokes and
|
||||||
|
// prev/next navigation. Export to PDF maps the normalized strokes into each
|
||||||
|
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
|
||||||
|
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
|
||||||
|
// to PDF is how annotations are kept), matching the previous behavior.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||||
|
|
||||||
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
|
import '../layout/viewport_fit.dart';
|
||||||
|
import '../pdf/slide_export.dart';
|
||||||
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_palette_widgets.dart';
|
||||||
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
class PenSlideScreen extends StatefulWidget {
|
||||||
|
const PenSlideScreen({
|
||||||
|
super.key,
|
||||||
|
required this.filePath,
|
||||||
|
required this.slideImagePaths,
|
||||||
|
this.extractedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String filePath;
|
||||||
|
final List<String> slideImagePaths;
|
||||||
|
final String? extractedText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PenSlideScreen> createState() => _PenSlideScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||||
|
int _slideIndex = 0;
|
||||||
|
final Map<int, List<PenStroke>> _strokesBySlide = {};
|
||||||
|
final Map<int, List<List<PenStroke>>> _undo = {};
|
||||||
|
final Map<int, List<List<PenStroke>>> _redo = {};
|
||||||
|
|
||||||
|
/// Intrinsic pixel size of each slide image, loaded async so the page rect
|
||||||
|
/// keeps the slide's aspect (no distortion). Null until loaded.
|
||||||
|
Map<int, Size>? _slideSizes;
|
||||||
|
|
||||||
|
CanvasTool _tool = CanvasTool.pen;
|
||||||
|
Color _color = Colors.black;
|
||||||
|
bool _allowFingerDrawing = false;
|
||||||
|
bool _needsCenter = true;
|
||||||
|
bool _showSlider = false;
|
||||||
|
double? _scrub;
|
||||||
|
|
||||||
|
PenConfigController? _penConfig;
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
|
static const double _penWidthFraction = 0.006;
|
||||||
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
static const Size _fallbackSlide = Size(1600, 900);
|
||||||
|
|
||||||
|
static const List<Color> _palette = [
|
||||||
|
Colors.black,
|
||||||
|
Colors.red,
|
||||||
|
Colors.blue,
|
||||||
|
Colors.green,
|
||||||
|
Colors.orange,
|
||||||
|
];
|
||||||
|
|
||||||
|
int get _slideCount => widget.slideImagePaths.length;
|
||||||
|
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
PenInputService.instance.start();
|
||||||
|
_loadSlideSizes();
|
||||||
|
_initPenConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSlideSizes() async {
|
||||||
|
final sizes = <int, Size>{};
|
||||||
|
for (var i = 0; i < _slideCount; i++) {
|
||||||
|
try {
|
||||||
|
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
||||||
|
final codec = await ui.instantiateImageCodec(bytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
sizes[i] = Size(
|
||||||
|
frame.image.width.toDouble(), frame.image.height.toDouble());
|
||||||
|
frame.image.dispose();
|
||||||
|
} catch (_) {
|
||||||
|
sizes[i] = _fallbackSlide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _slideSizes = sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPenConfig() async {
|
||||||
|
final controller = await PenConfigController.load();
|
||||||
|
if (!mounted) {
|
||||||
|
controller.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.addListener(_onPenConfigChanged);
|
||||||
|
setState(() {
|
||||||
|
_penConfig = controller;
|
||||||
|
_allowFingerDrawing = controller.value.fingerDrawing;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenConfigChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
|
_penConfig?.dispose();
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _pushUndo() {
|
||||||
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_redo[_slideIndex]?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _commitStroke(PenStroke stroke) {
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
||||||
|
final strokes = _currentStrokes;
|
||||||
|
if (index < 0 || index >= strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokesBySlide[_slideIndex] = [
|
||||||
|
...strokes.sublist(0, index),
|
||||||
|
...replacements,
|
||||||
|
...strokes.sublist(index + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performUndo() {
|
||||||
|
final stack = _undo[_slideIndex];
|
||||||
|
if (stack == null || stack.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performRedo() {
|
||||||
|
final stack = _redo[_slideIndex];
|
||||||
|
if (stack == null || stack.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFingerDrawing() {
|
||||||
|
final next = !_allowFingerDrawing;
|
||||||
|
setState(() => _allowFingerDrawing = next);
|
||||||
|
_penConfig?.setFingerDrawing(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToSlide(int i) {
|
||||||
|
final clamped = i.clamp(0, _slideCount - 1);
|
||||||
|
if (clamped == _slideIndex) return;
|
||||||
|
setState(() {
|
||||||
|
_slideIndex = clamped;
|
||||||
|
_needsCenter = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _exportPdf() async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Exporting PDF...')));
|
||||||
|
try {
|
||||||
|
final bytes = await _buildPdfBytes();
|
||||||
|
final dir = await _exportDir();
|
||||||
|
final base = p.basenameWithoutExtension(widget.filePath);
|
||||||
|
final outPath = p.join(dir.path, '${base}_annotated.pdf');
|
||||||
|
await File(outPath).writeAsBytes(bytes);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _exportDir() 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;
|
||||||
|
final sizes = _slideSizes ?? const {};
|
||||||
|
for (var i = 0; i < _slideCount; i++) {
|
||||||
|
final page = doc.pages.add();
|
||||||
|
final pageSize = page.getClientSize();
|
||||||
|
try {
|
||||||
|
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
||||||
|
final bitmap = PdfBitmap(imgBytes);
|
||||||
|
final imageSize = sizes[i] ??
|
||||||
|
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
|
||||||
|
final draw = slideDrawRect(
|
||||||
|
Size(pageSize.width, pageSize.height), imageSize);
|
||||||
|
page.graphics.drawImage(bitmap, draw);
|
||||||
|
|
||||||
|
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
|
||||||
|
if (stroke.points.length < 2) continue;
|
||||||
|
final r = (stroke.color >> 16) & 0xFF;
|
||||||
|
final g = (stroke.color >> 8) & 0xFF;
|
||||||
|
final b = stroke.color & 0xFF;
|
||||||
|
final path = PdfPath();
|
||||||
|
path.startFigure();
|
||||||
|
for (var j = 0; j < stroke.points.length - 1; j++) {
|
||||||
|
final p1 = stroke.points[j];
|
||||||
|
final p2 = stroke.points[j + 1];
|
||||||
|
path.addLine(
|
||||||
|
normToSlide(p1.x, p1.y, draw),
|
||||||
|
normToSlide(p2.x, p2.y, draw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
page.graphics.drawPath(
|
||||||
|
path,
|
||||||
|
pen: PdfPen(PdfColor(r, g, b),
|
||||||
|
width: slideStrokeWidth(stroke.width, draw)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _centerPage(Size viewport, Size pageSize) {
|
||||||
|
final o = centerOffset(pageSize, viewport, 1.0);
|
||||||
|
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||||
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
|
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _buildCanvas()),
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _buildToolPalette(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.arrow_back,
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.picture_as_pdf_outlined,
|
||||||
|
tooltip: 'Export to PDF',
|
||||||
|
onPressed: _exportPdf,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_slideCount > 0)
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: _buildSlidePill(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCanvas() {
|
||||||
|
final sizes = _slideSizes;
|
||||||
|
if (sizes == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_slideCount == 0) {
|
||||||
|
return const Center(child: Text('No slides.'));
|
||||||
|
}
|
||||||
|
final slide = sizes[_slideIndex] ?? _fallbackSlide;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final fitW = constraints.maxWidth / slide.width;
|
||||||
|
final fitH = constraints.maxHeight / slide.height;
|
||||||
|
final scale = fitW < fitH ? fitW : fitH;
|
||||||
|
final pageSize = Size(slide.width * scale, slide.height * scale);
|
||||||
|
|
||||||
|
if (_needsCenter) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_centerPage(
|
||||||
|
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
|
||||||
|
setState(() => _needsCenter = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PenCanvas(
|
||||||
|
key: ValueKey(_slideIndex),
|
||||||
|
pageSize: pageSize,
|
||||||
|
strokes: _currentStrokes,
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: _tool,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: _strokeWidth,
|
||||||
|
pressureGamma:
|
||||||
|
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
|
||||||
|
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||||
|
sideButtonAction:
|
||||||
|
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
||||||
|
eraserEndAction:
|
||||||
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||||
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
|
onStrokeComplete: _commitStroke,
|
||||||
|
onEraseStroke: _eraseStroke,
|
||||||
|
pageWidget: Image.file(
|
||||||
|
File(widget.slideImagePaths[_slideIndex]),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildToolPalette(ColorScheme cs) {
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.edit_outlined,
|
||||||
|
selected: _tool == CanvasTool.pen,
|
||||||
|
tooltip: 'Pen',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.brush_outlined,
|
||||||
|
selected: _tool == CanvasTool.highlighter,
|
||||||
|
tooltip: 'Highlighter',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.cleaning_services_outlined,
|
||||||
|
selected: _tool == CanvasTool.eraser,
|
||||||
|
tooltip: 'Eraser',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.undo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Undo',
|
||||||
|
onPressed:
|
||||||
|
(_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.redo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Redo',
|
||||||
|
onPressed:
|
||||||
|
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
|
final selected =
|
||||||
|
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_color = c;
|
||||||
|
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||||
|
}),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? cs.onSurface : cs.outlineVariant,
|
||||||
|
width: selected ? 3 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSlidePill(ColorScheme cs) {
|
||||||
|
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_showSlider && _slideCount > 1)
|
||||||
|
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: _slideCount.toDouble(),
|
||||||
|
value: (_scrub ?? (_slideIndex + 1).toDouble())
|
||||||
|
.clamp(1, _slideCount.toDouble()),
|
||||||
|
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
||||||
|
onChanged: (v) => setState(() => _scrub = v),
|
||||||
|
onChangeEnd: (v) {
|
||||||
|
setState(() => _scrub = null);
|
||||||
|
_goToSlide(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 slide',
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed:
|
||||||
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _slideCount > 1
|
||||||
|
? () => setState(() => _showSlider = !_showSlider)
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
'$shown / $_slideCount',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Next slide',
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: _slideIndex < _slideCount - 1
|
||||||
|
? () => _goToSlide(_slideIndex + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// lib/editor/pdf/slide_export.dart
|
||||||
|
//
|
||||||
|
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
|
||||||
|
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
|
||||||
|
// just maps each normalized point into the slide image's draw rectangle on the
|
||||||
|
// PDF page — no live-widget-size guessing, which is what made the old PPT
|
||||||
|
// exporter misalign ink (see the removed ppt_annotator_screen comment).
|
||||||
|
|
||||||
|
import 'dart:ui' show Offset, Rect, Size;
|
||||||
|
|
||||||
|
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
|
||||||
|
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
|
||||||
|
/// exported ink lands exactly where it was drawn.
|
||||||
|
Rect slideDrawRect(Size page, Size image) {
|
||||||
|
final iw = image.width <= 0 ? 1.0 : image.width;
|
||||||
|
final ih = image.height <= 0 ? 1.0 : image.height;
|
||||||
|
final scale = (page.width / iw) < (page.height / ih)
|
||||||
|
? page.width / iw
|
||||||
|
: page.height / ih;
|
||||||
|
final drawW = iw * scale;
|
||||||
|
final drawH = ih * scale;
|
||||||
|
final offX = (page.width - drawW) / 2;
|
||||||
|
final offY = (page.height - drawH) / 2;
|
||||||
|
return Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
|
||||||
|
/// inside the slide's [drawRect] on the PDF page.
|
||||||
|
Offset normToSlide(double nx, double ny, Rect drawRect) =>
|
||||||
|
Offset(drawRect.left + nx * drawRect.width,
|
||||||
|
drawRect.top + ny * drawRect.height);
|
||||||
|
|
||||||
|
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
|
||||||
|
/// page width, scaled into [drawRect].
|
||||||
|
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
|
||||||
|
normalizedWidth * drawRect.width;
|
||||||
@@ -10,7 +10,7 @@ import '../editor/canvas/pen_editor_screen.dart';
|
|||||||
import '../services/pdf_service.dart';
|
import '../services/pdf_service.dart';
|
||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
import '../editor/canvas/pen_note_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import 'ppt_annotator_screen.dart';
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'split_view_screen.dart';
|
import 'split_view_screen.dart';
|
||||||
@@ -222,7 +222,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PptAnnotatorScreen(
|
builder: (_) => PenSlideScreen(
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
slideImagePaths: slideImages,
|
slideImagePaths: slideImages,
|
||||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
@@ -527,7 +527,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen
|
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen
|
||||||
Future<void> _openDocument(BuildContext context) async {
|
Future<void> _openDocument(BuildContext context) async {
|
||||||
final document = widget.document;
|
final document = widget.document;
|
||||||
final isPdf = document.docType == 'pdf';
|
final isPdf = document.docType == 'pdf';
|
||||||
@@ -539,7 +539,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// PPT/PPTX: convert to images then push PptAnnotatorScreen
|
// PPT/PPTX: convert to images then push PenSlideScreen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
const SnackBar(content: Text('Processing presentation...')),
|
const SnackBar(content: Text('Processing presentation...')),
|
||||||
@@ -557,7 +557,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
}
|
}
|
||||||
Navigator.of(this.context).push(
|
Navigator.of(this.context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PptAnnotatorScreen(
|
builder: (_) => PenSlideScreen(
|
||||||
filePath: document.filePath,
|
filePath: document.filePath,
|
||||||
slideImagePaths: slideImages,
|
slideImagePaths: slideImages,
|
||||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
test/slide_export_test.dart
Normal file
45
test/slide_export_test.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Proves the pen-first slide export geometry: contain-fit centering and the
|
||||||
|
// normalized->slide point mapping that replaces the old misaligned exporter.
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect, Size;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/pdf/slide_export.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('wide image is letterboxed top/bottom on a square page', () {
|
||||||
|
final r = slideDrawRect(const Size(1000, 1000), const Size(1600, 900));
|
||||||
|
// scale = min(1000/1600, 1000/900) = 0.625 -> 1000 x 562.5, centered.
|
||||||
|
expect(r.width, closeTo(1000, 1e-6));
|
||||||
|
expect(r.height, closeTo(562.5, 1e-6));
|
||||||
|
expect(r.left, closeTo(0, 1e-6));
|
||||||
|
expect(r.top, closeTo((1000 - 562.5) / 2, 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tall image is pillarboxed left/right', () {
|
||||||
|
final r = slideDrawRect(const Size(1000, 1000), const Size(900, 1600));
|
||||||
|
expect(r.height, closeTo(1000, 1e-6));
|
||||||
|
expect(r.width, closeTo(562.5, 1e-6));
|
||||||
|
expect(r.top, closeTo(0, 1e-6));
|
||||||
|
expect(r.left, closeTo((1000 - 562.5) / 2, 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalized corners map to the draw rect corners', () {
|
||||||
|
final r = const Rect.fromLTWH(100, 50, 800, 600);
|
||||||
|
final tl = normToSlide(0, 0, r);
|
||||||
|
final br = normToSlide(1, 1, r);
|
||||||
|
final mid = normToSlide(0.5, 0.5, r);
|
||||||
|
expect(tl.dx, closeTo(100, 1e-9));
|
||||||
|
expect(tl.dy, closeTo(50, 1e-9));
|
||||||
|
expect(br.dx, closeTo(900, 1e-9));
|
||||||
|
expect(br.dy, closeTo(650, 1e-9));
|
||||||
|
expect(mid.dx, closeTo(500, 1e-9));
|
||||||
|
expect(mid.dy, closeTo(350, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stroke width scales with the draw width', () {
|
||||||
|
final r = const Rect.fromLTWH(0, 0, 800, 600);
|
||||||
|
expect(slideStrokeWidth(0.01, r), closeTo(8, 1e-9));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user