diff --git a/lib/screens/split_view_screen.dart b/lib/screens/split_view_screen.dart index 2cfe018..89fb926 100644 --- a/lib/screens/split_view_screen.dart +++ b/lib/screens/split_view_screen.dart @@ -4,7 +4,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:uuid/uuid.dart'; +import '../editor/canvas/pen_canvas.dart'; +import '../editor/canvas/pen_stroke.dart'; +import '../editor/notebook/ink_stroke_adapter.dart'; import '../models/ink_stroke.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; @@ -12,7 +16,6 @@ import '../services/database_service.dart'; import '../services/undo_manager.dart'; import '../utils/stroke_stabilizer.dart'; import '../widgets/annotation_toolbar.dart'; -import '../widgets/ink_canvas.dart'; /// Split-view derivation mode: left pane = reference PDF, right pane = infinite /// scratchpad for formula derivation. Scratchpad strokes are persisted per @@ -43,10 +46,29 @@ class _SplitViewState extends State { bool _isDraggingDivider = false; // -- Scratchpad (right pane) -- + // The scratchpad is an infinite WORLD: strokes are stored in absolute world + // pixels ([InkStroke], unchanged persistence format), and rendered through the + // performant PenCanvas by normalizing against the CURRENT world size. When the + // world auto-expands, the stored world coords don't move — only the + // normalization divisor grows — so ink stays put with zero drift. final UndoManager _undoManager = UndoManager(); List _strokes = []; double _canvasWidth = 4000; double _canvasHeight = 4000; + static const _uuid = Uuid(); + + /// Pan/zoom transform for the scratchpad world (PenCanvas drives this). + final TransformationController _scratchTransform = TransformationController(); + + Size get _worldSize => Size(_canvasWidth, _canvasHeight); + + /// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and + /// text fall back to pen (the pen-first scratchpad is freehand). + CanvasTool get _canvasTool => switch (_currentTool) { + PenTool.eraser => CanvasTool.eraser, + PenTool.highlighter => CanvasTool.highlighter, + _ => CanvasTool.pen, + }; // -- Tool state -- PenTool _currentTool = PenTool.pen; @@ -77,6 +99,7 @@ class _SplitViewState extends State { _saveTimer?.cancel(); _saveImmediate(); _pdfController.dispose(); + _scratchTransform.dispose(); super.dispose(); } @@ -87,8 +110,11 @@ class _SplitViewState extends State { final strokes = await db.loadScratchpad(widget.documentId); if (mounted) { setState(() { - _strokes = strokes; - for (final s in strokes) { + // Keep only freehand strokes so the canvas list stays 1:1 with the + // undo manager (shapes/text have no pen-canvas representation). + final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList(); + _strokes = freehand; + for (final s in freehand) { _undoManager.addStroke(s); } }); @@ -111,7 +137,11 @@ class _SplitViewState extends State { // -- Scratchpad stroke callbacks -- - void _onStrokeComplete(InkStroke stroke) { + /// PenCanvas committed a stroke (normalized to the current world). Convert it + /// to absolute world coords for storage. + void _onStrokeComplete(PenStroke pen) { + final stroke = inkStrokeFromPen(pen, _worldSize, + id: _uuid.v4(), createdAt: DateTime.now()); setState(() { _strokes.add(stroke); _undoManager.addStroke(stroke); @@ -120,13 +150,19 @@ class _SplitViewState extends State { _scheduleSave(); } - void _onErase(String strokeId, List replacements) { + /// PenCanvas erased through stroke [index] (into [_strokes]); [replacements] + /// are the surviving sub-strokes (normalized) — convert back to world coords. + void _onErase(int index, List replacements) { + if (index < 0 || index >= _strokes.length) return; setState(() { - final original = _strokes.where((s) => s.id == strokeId).firstOrNull; - if (original != null) { - _undoManager.removeStroke(original, replacements: replacements); - _strokes = List.from(_undoManager.currentStrokes); - } + final original = _strokes[index]; + final inkReplacements = [ + for (final r in replacements) + inkStrokeFromPen(r, _worldSize, + id: _uuid.v4(), createdAt: DateTime.now()), + ]; + _undoManager.removeStroke(original, replacements: inkReplacements); + _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } @@ -432,29 +468,24 @@ class _SplitViewState extends State { } Widget _buildScratchpadPane() { + // Render the world through the performant PenCanvas: strokes normalized + // against the current world size; toolbar width is in world pixels, so the + // pen-canvas fraction is width / worldWidth. return Container( color: Theme.of(context).scaffoldBackgroundColor, - child: InteractiveViewer( - constrained: false, - minScale: 0.25, + child: PenCanvas( + pageSize: _worldSize, + strokes: penStrokesFromInk(_strokes, _worldSize), + transformationController: _scratchTransform, + tool: _canvasTool, + color: _currentColor, + strokeWidth: _currentStrokeWidth / _canvasWidth, + // The world is huge, so allow zooming further out to survey it. + minScale: 0.1, maxScale: 8.0, - boundaryMargin: const EdgeInsets.all(double.infinity), - child: SizedBox( - width: _canvasWidth, - height: _canvasHeight, - child: InkCanvas( - strokes: _strokes, - onStrokeComplete: _onStrokeComplete, - onErase: _onErase, - tool: _currentTool, - color: _currentColor, - strokeWidth: _currentStrokeWidth, - pressureCurve: PressureCurve(type: _pressureCurveType), - stabilizationLevel: _stabilizationLevel, - filled: _filled, - interactionMode: InteractionMode.draw, - ), - ), + onStrokeComplete: _onStrokeComplete, + onEraseStroke: _onErase, + pageWidget: const ColoredBox(color: Colors.white), ), ); } diff --git a/test/ink_stroke_adapter_test.dart b/test/ink_stroke_adapter_test.dart index 850bb5b..93a4cc4 100644 --- a/test/ink_stroke_adapter_test.dart +++ b/test/ink_stroke_adapter_test.dart @@ -83,6 +83,31 @@ void main() { } }); + test('world coords are stable when the scratchpad world expands', () { + // A stroke drawn at the center of a 4000-world: normalized 0.5 -> world + // 2000. This is how the split-view scratchpad stores ink. + final pen0 = PenStroke( + points: const [PenPoint(0.5, 0.5, 0.6), PenPoint(0.6, 0.6, 0.6)], + color: 0xFF000000, + width: 0.002, + kind: PenStrokeKind.pen, + ); + final stored = inkStrokeFromPen(pen0, const Size(4000, 4000), + id: 'w', createdAt: t0); + expect(stored.points.first.x, closeTo(2000, 1e-6)); + expect(stored.points.first.y, closeTo(2000, 1e-6)); + + // The world auto-expands to 8000. The STORED world coords are untouched; + // only the render-time normalization divisor changes. + final pen1 = penStrokeFromInk(stored, const Size(8000, 8000))!; + expect(pen1.points.first.x, closeTo(0.25, 1e-9)); // 2000 / 8000 + expect(pen1.points.first.y, closeTo(0.25, 1e-9)); + + // Crucially, the ABSOLUTE on-page position is unchanged: normalized * + // pageWidth = 0.25 * 8000 = 2000 == the original world x. Zero drift. + expect(pen1.points.first.x * 8000, closeTo(2000, 1e-6)); + }); + test('penStrokesFromInk preserves order and filters non-freehand', () { final strokes = [ ink([ip(0, 0), ip(1, 1)], tool: PenTool.pen, color: 0xFF000001),