Some checks failed
CI / Windows build (push) Has been cancelled
The pen-first scratchpad opened at identity transform, showing only the
empty top-left corner of the 4000x4000 world — so existing ink (drawn
elsewhere) was off-screen and the pane looked blank ("草稿纸根本没看到").
On first layout, fit the strokes' world bounding box into the pane (padded,
scale clamped 0.15-1.5) so saved ink is immediately visible; an empty
scratchpad falls back to a 1:1 view near the origin.
flutter analyze: 0 issues.
559 lines
18 KiB
Dart
559 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
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';
|
|
import '../services/database_service.dart';
|
|
import '../services/undo_manager.dart';
|
|
import '../utils/stroke_stabilizer.dart';
|
|
import '../widgets/annotation_toolbar.dart';
|
|
|
|
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
|
|
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
|
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
|
class SplitViewScreen extends StatefulWidget {
|
|
final String filePath;
|
|
final String documentId;
|
|
|
|
const SplitViewScreen({
|
|
super.key,
|
|
required this.filePath,
|
|
required this.documentId,
|
|
});
|
|
|
|
@override
|
|
State<SplitViewScreen> createState() => _SplitViewState();
|
|
}
|
|
|
|
class _SplitViewState extends State<SplitViewScreen> {
|
|
// -- PDF (left pane) --
|
|
final PdfViewerController _pdfController = PdfViewerController();
|
|
int _currentPage = 0;
|
|
int _pageCount = 0;
|
|
String _fileName = '';
|
|
|
|
// -- Split divider --
|
|
double _leftPaneFraction = 0.5;
|
|
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<InkStroke> _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();
|
|
|
|
/// Set once the initial view has been framed onto existing ink.
|
|
bool _scratchCentered = false;
|
|
|
|
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;
|
|
Color _currentColor = Colors.black;
|
|
double _currentStrokeWidth = 2.0;
|
|
bool _filled = false;
|
|
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
|
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
|
|
|
// -- Auto-save debounce --
|
|
Timer? _saveTimer;
|
|
bool _dirty = false;
|
|
|
|
// -- Page link markers (optional feature) --
|
|
final List<_PageLink> _pageLinks = [];
|
|
|
|
static const double _edgeThreshold = 200.0;
|
|
static const double _expandAmount = 1000.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadScratchpad();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_saveTimer?.cancel();
|
|
_saveImmediate();
|
|
_pdfController.dispose();
|
|
_scratchTransform.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// -- Persistence --
|
|
|
|
Future<void> _loadScratchpad() async {
|
|
final db = await DatabaseService.getInstance();
|
|
final strokes = await db.loadScratchpad(widget.documentId);
|
|
if (mounted) {
|
|
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<void> _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.documentId, json);
|
|
}
|
|
|
|
// -- Scratchpad stroke callbacks --
|
|
|
|
/// 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);
|
|
_checkCanvasExpansion(stroke);
|
|
});
|
|
_scheduleSave();
|
|
}
|
|
|
|
/// PenCanvas erased through stroke [index] (into [_strokes]); [replacements]
|
|
/// are the surviving sub-strokes (normalized) — convert back to world coords.
|
|
void _onErase(int index, List<PenStroke> 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 --
|
|
|
|
void _prevPage() {
|
|
if (_currentPage > 0) {
|
|
_pdfController.previousPage();
|
|
}
|
|
}
|
|
|
|
void _nextPage() {
|
|
if (_currentPage < _pageCount - 1) {
|
|
_pdfController.nextPage();
|
|
}
|
|
}
|
|
|
|
// -- Page link creation (long-press on left pane) --
|
|
|
|
void _onPdfLongPress(int pageNumber) {
|
|
// Place a page link marker at the current scratchpad viewport center.
|
|
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
|
// manages its own transform — the user can reposition by panning.
|
|
setState(() {
|
|
_pageLinks.add(
|
|
_PageLink(
|
|
pageNumber: pageNumber,
|
|
position: const Offset(100, 100), // default top-left area
|
|
),
|
|
);
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Page link marker added for page $pageNumber')),
|
|
);
|
|
}
|
|
|
|
void _onPageLinkTap(_PageLink link) {
|
|
_pdfController.jumpToPage(link.pageNumber);
|
|
setState(() {
|
|
_currentPage = link.pageNumber - 1;
|
|
});
|
|
}
|
|
|
|
void _deletePageLink(_PageLink link) {
|
|
setState(() {
|
|
_pageLinks.remove(link);
|
|
});
|
|
}
|
|
|
|
// -- Build --
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
_fileName.isEmpty ? 'Split View' : _fileName,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
_saveImmediate();
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
actions: [
|
|
// Left pane page navigation
|
|
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),
|
|
// Canvas info
|
|
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: [
|
|
// Label clarifying that the toolbar controls the scratchpad pane.
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Toolbar (applies to scratchpad only)
|
|
AnnotationToolbar(
|
|
currentTool: _currentTool,
|
|
currentColor: _currentColor,
|
|
currentStrokeWidth: _currentStrokeWidth,
|
|
filled: _filled,
|
|
pressureCurveType: _pressureCurveType,
|
|
stabilizationLevel: _stabilizationLevel,
|
|
canUndo: _undoManager.canUndo,
|
|
canRedo: _undoManager.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,
|
|
),
|
|
// Split view body
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final totalWidth = constraints.maxWidth;
|
|
final leftWidth = totalWidth * _leftPaneFraction;
|
|
final rightWidth =
|
|
totalWidth - leftWidth - 12; // 12px divider hit area
|
|
|
|
return Row(
|
|
children: [
|
|
// Left pane: PDF reference (read-only)
|
|
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
|
// Draggable divider: 12px hit area, 4px visual strip.
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Right pane: Infinite scratchpad
|
|
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPdfPane() {
|
|
return Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onLongPress: () {
|
|
// Long-press on PDF to create page link marker
|
|
_onPdfLongPress(_currentPage + 1);
|
|
},
|
|
child: SfPdfViewer.file(
|
|
File(widget.filePath),
|
|
controller: _pdfController,
|
|
canShowScrollHead: true,
|
|
canShowScrollStatus: true,
|
|
onPageChanged: (PdfPageChangedDetails details) {
|
|
setState(() {
|
|
_currentPage = details.newPageNumber - 1;
|
|
});
|
|
},
|
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
|
setState(() {
|
|
_pageCount = details.document.pages.count;
|
|
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
// Page link markers overlay (on PDF pane, showing linked pages)
|
|
if (_pageLinks.isNotEmpty)
|
|
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPageLinkChips() {
|
|
return Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children: _pageLinks.map((link) {
|
|
return GestureDetector(
|
|
onTap: () => _onPageLinkTap(link),
|
|
onLongPress: () => _deletePageLink(link),
|
|
child: Chip(
|
|
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
|
label: Text(
|
|
'p${link.pageNumber}',
|
|
style: const TextStyle(fontSize: 11, color: Colors.white),
|
|
),
|
|
backgroundColor: Colors.blue.shade600,
|
|
padding: EdgeInsets.zero,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
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 LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// On first layout, frame the view so existing ink is actually visible
|
|
// (otherwise identity shows only the empty top-left corner of the huge
|
|
// world). Empty scratchpad falls back to a comfortable 1:1 near origin.
|
|
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: _canvasTool,
|
|
color: _currentColor,
|
|
strokeWidth: _currentStrokeWidth / _canvasWidth,
|
|
// The world is huge, so allow zooming further out to survey it.
|
|
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. Fits the strokes'
|
|
/// world bounding box into [pane] (with padding, scale clamped); for an empty
|
|
/// scratchpad, shows the top-left working area 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);
|
|
}
|
|
}
|
|
|
|
/// A marker linking a scratchpad position to a specific PDF page.
|
|
class _PageLink {
|
|
final int pageNumber;
|
|
final Offset position;
|
|
|
|
const _PageLink({required this.pageNumber, required this.position});
|
|
}
|