// lib/screens/split_view_screen.dart // // Anchor-keyed split view: opened by tapping a PDF-anchored scratch link. // LEFT pane = the source PDF (pdfrx PdfViewer, read-only reference + page // nav), opened at the anchor's page. // RIGHT pane = an INFINITE freehand scratchpad that BELONGS TO THE ANCHOR, // keyed by [scratchLinkId] (not the documentId). Each anchor has // its own private scratch space, persisted via the existing // scratchpad storage (InkStroke JSON, format unchanged). // // The right pane reuses the pen-first PenCanvas world-coord engine and the // Material You brush palette shared with pen_note_screen (BrushPickerButton / // ToolButton / color dots), replacing the old AnnotationToolbar. import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:uuid/uuid.dart'; import '../editor/canvas/pen_canvas.dart'; import '../editor/canvas/pen_palette_widgets.dart'; import '../editor/canvas/pen_stroke.dart'; import '../editor/engine/brush.dart'; import '../editor/input/pen_config.dart' show kDefaultEraserRadius; import '../editor/notebook/ink_stroke_adapter.dart'; import '../models/ink_stroke.dart'; import '../services/database_service.dart'; import '../services/undo_manager.dart'; /// Split-view derivation surface for a single scratch-link anchor: left pane = /// the reference PDF (at [initialPage]), right pane = the anchor's private /// infinite scratchpad. Scratchpad strokes persist per ANCHOR via /// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by /// [scratchLinkId]. class SplitViewScreen extends StatefulWidget { final String filePath; final String documentId; /// The owning anchor id. Doubles as the scratchpad storage key so this is the /// anchor's private scratch space. final String scratchLinkId; /// 0-based page the anchor sits on; the left PDF opens here. final int initialPage; const SplitViewScreen({ super.key, required this.filePath, required this.documentId, required this.scratchLinkId, this.initialPage = 0, }); @override State createState() => _SplitViewState(); } class _SplitViewState extends State { // -- PDF (left pane) -- final PdfViewerController _pdfController = PdfViewerController(); int _currentPage = 0; // 0-based int _pageCount = 0; // -- Split divider -- double _leftPaneFraction = 0.5; bool _isDraggingDivider = false; // -- Scratchpad (right pane) -- // Infinite WORLD: strokes stored in absolute world pixels ([InkStroke], // unchanged persistence format), rendered through PenCanvas by normalizing // against the CURRENT world size. World auto-expands without moving ink. final UndoManager _undoManager = UndoManager(); List _strokes = []; double _canvasWidth = 4000; double _canvasHeight = 4000; static const _uuid = Uuid(); final TransformationController _scratchTransform = TransformationController(); bool _scratchCentered = false; Size get _worldSize => Size(_canvasWidth, _canvasHeight); // -- Tool state (new Material You brush palette) -- CanvasTool _tool = CanvasTool.pen; BrushKind _penBrush = BrushKind.fountainPen; Color _color = Colors.black; static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; static const List _palette = [ Colors.black, Colors.red, Colors.blue, Colors.green, Colors.orange, ]; // -- Auto-save debounce -- Timer? _saveTimer; bool _dirty = false; static const double _edgeThreshold = 200.0; static const double _expandAmount = 1000.0; @override void initState() { super.initState(); _currentPage = widget.initialPage; _loadScratchpad(); } @override void dispose() { _saveTimer?.cancel(); _saveImmediate(); // PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer. _scratchTransform.dispose(); super.dispose(); } // -- Persistence (keyed by the ANCHOR id, not the documentId) -- Future _loadScratchpad() async { final db = await DatabaseService.getInstance(); final strokes = await db.loadScratchpad(widget.scratchLinkId); if (!mounted) return; setState(() { // 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); } }); } void _scheduleSave() { _dirty = true; _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 3), _saveImmediate); } Future _saveImmediate() async { if (!_dirty) return; _dirty = false; final db = await DatabaseService.getInstance(); final json = jsonEncode(_strokes.map((s) => s.toJson()).toList()); await db.saveScratchpad(widget.scratchLinkId, json); } // -- Scratchpad stroke callbacks -- void _onStrokeComplete(PenStroke pen) { final stroke = inkStrokeFromPen(pen, _worldSize, id: _uuid.v4(), createdAt: DateTime.now()); setState(() { _strokes.add(stroke); _undoManager.addStroke(stroke); _checkCanvasExpansion(stroke); }); _scheduleSave(); } void _onErase(int index, List replacements) { if (index < 0 || index >= _strokes.length) return; setState(() { 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(); } void _undo() { setState(() { _undoManager.undo(); _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } void _redo() { setState(() { _undoManager.redo(); _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } // -- Auto-expand canvas -- void _checkCanvasExpansion(InkStroke stroke) { double maxRight = 0; double maxBottom = 0; for (final p in stroke.points) { if (p.x > maxRight) maxRight = p.x; if (p.y > maxBottom) maxBottom = p.y; } bool expanded = false; if (maxRight > _canvasWidth - _edgeThreshold) { _canvasWidth += _expandAmount; expanded = true; } if (maxBottom > _canvasHeight - _edgeThreshold) { _canvasHeight += _expandAmount; expanded = true; } if (expanded) setState(() {}); } // -- Divider drag -- void _onDividerDragStart(DragStartDetails details) { setState(() => _isDraggingDivider = true); } void _onDividerDragUpdate( DragUpdateDetails details, BoxConstraints constraints, ) { final renderWidth = constraints.maxWidth; if (renderWidth <= 0) return; final delta = details.delta.dx / renderWidth; setState(() { _leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8); }); } void _onDividerDragEnd(DragEndDetails details) { setState(() => _isDraggingDivider = false); } // -- PDF page navigation (left pane) -- void _prevPage() { if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage); } void _nextPage() { if (_currentPage < _pageCount - 1) { _pdfController.goToPage(pageNumber: _currentPage + 2); } } CanvasTool get _activeTool => _tool; double get _strokeWidth => _tool == CanvasTool.highlighter ? _highlighterWidthFraction : _penWidthFraction; // -- Build -- @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( title: const Text('Scratch link', style: TextStyle(fontSize: 16)), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { _saveImmediate(); Navigator.of(context).pop(); }, ), actions: [ IconButton( icon: const Icon(Icons.navigate_before), tooltip: 'Previous page (PDF)', onPressed: _currentPage > 0 ? _prevPage : null, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Center( child: Text( '${_currentPage + 1} / $_pageCount', style: const TextStyle(fontSize: 13), ), ), ), IconButton( icon: const Icon(Icons.navigate_next), tooltip: 'Next page (PDF)', onPressed: _currentPage < _pageCount - 1 ? _nextPage : null, ), const SizedBox(width: 8), Tooltip( message: 'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}', child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Center( child: Text( '${_canvasWidth.round()}x${_canvasHeight.round()}', style: const TextStyle(fontSize: 11, color: Colors.grey), ), ), ), ), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.only(left: 12, top: 4), child: Align( alignment: Alignment.centerLeft, child: Text( 'Scratchpad tools', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ), _buildBrushPalette(cs), Expanded( child: LayoutBuilder( builder: (context, constraints) { final totalWidth = constraints.maxWidth; final leftWidth = totalWidth * _leftPaneFraction; final rightWidth = totalWidth - leftWidth - 12; return Row( children: [ SizedBox(width: leftWidth, child: _buildPdfPane()), GestureDetector( onHorizontalDragStart: _onDividerDragStart, onHorizontalDragUpdate: (d) => _onDividerDragUpdate(d, constraints), onHorizontalDragEnd: _onDividerDragEnd, child: MouseRegion( cursor: SystemMouseCursors.resizeColumn, child: SizedBox( width: 12, child: Center( child: Container( width: 4, color: _isDraggingDivider ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), ), ), ), ), SizedBox(width: rightWidth, child: _buildScratchpadPane()), ], ); }, ), ), ], ), ); } /// Material You brush palette (shared chrome with the pen-first editors): /// brush picker + highlighter + eraser, undo/redo, color dots. Widget _buildBrushPalette(ColorScheme cs) { return Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ BrushPickerButton( selected: _penBrush, active: _tool == CanvasTool.pen, tooltip: 'Brush', labelFor: brushLabelEn, // The scratchpad keeps a single shared color (no per-brush color // memory in this surface); show it for every brush. colorFor: (_) => _color, onSelected: (b) => setState(() { _penBrush = b; _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: _undoManager.canUndo ? _undo : null, ), ToolButton( icon: Icons.redo, selected: false, tooltip: 'Redo', onPressed: _undoManager.canRedo ? _redo : null, ), PaletteDivider(cs: cs), for (final c in _palette) _colorDot(c, cs), ], ), ), ), ); } 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 _buildPdfPane() { // Read-only reference PDF on the same engine (pdfrx) as the rest of the // app, opened at the anchor's page. return PdfViewer.file( widget.filePath, controller: _pdfController, params: PdfViewerParams( onViewerReady: (document, controller) { if (!mounted) return; setState(() { _pageCount = document.pages.length; }); final target = widget.initialPage.clamp(0, _pageCount - 1); if (target > 0) { controller.goToPage(pageNumber: target + 1); } }, onPageChanged: (pageNumber) { if (pageNumber == null || !mounted) return; final idx = pageNumber - 1; if (idx != _currentPage) setState(() => _currentPage = idx); }, ), ); } Widget _buildScratchpadPane() { return LayoutBuilder( builder: (context, constraints) { if (!_scratchCentered) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _frameScratchpad( Size(constraints.maxWidth, constraints.maxHeight)); setState(() => _scratchCentered = true); }); } return Container( color: Theme.of(context).scaffoldBackgroundColor, child: PenCanvas( pageSize: _worldSize, strokes: penStrokesFromInk(_strokes, _worldSize), transformationController: _scratchTransform, tool: _activeTool, brush: _penBrush, color: _color, strokeWidth: _strokeWidth, eraserRadius: kDefaultEraserRadius, minScale: 0.1, maxScale: 8.0, onStrokeComplete: _onStrokeComplete, onEraseStroke: _onErase, pageWidget: const ColoredBox(color: Colors.white), ), ); }, ); } /// Position the scratchpad so existing ink is on-screen (fit its bbox into /// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1. void _frameScratchpad(Size pane) { if (pane.isEmpty) return; if (_strokes.isEmpty) { _scratchTransform.value = Matrix4.identity(); return; } double minX = double.infinity, minY = double.infinity; double maxX = -double.infinity, maxY = -double.infinity; for (final s in _strokes) { for (final p in s.points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } } if (minX > maxX) { _scratchTransform.value = Matrix4.identity(); return; } const pad = 80.0; final boxW = (maxX - minX) + pad * 2; final boxH = (maxY - minY) + pad * 2; final scale = (pane.width / boxW < pane.height / boxH ? pane.width / boxW : pane.height / boxH) .clamp(0.15, 1.5); final cx = (minX + maxX) / 2; final cy = (minY + maxY) / 2; final tx = pane.width / 2 - scale * cx; final ty = pane.height / 2 - scale * cy; _scratchTransform.value = Matrix4.identity() ..setEntry(0, 0, scale) ..setEntry(1, 1, scale) ..setEntry(2, 2, scale) ..setTranslationRaw(tx, ty, 0); } }