feat(split): scratchpad inks on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 20m37s

The split-view scratchpad was the last surface on the old ink_canvas. Move
its inking engine to the performant PenCanvas while keeping the infinite
auto-expanding world.

Key idea: store strokes in absolute WORLD pixels (InkStroke — unchanged
saveScratchpad format) and render through PenCanvas by normalizing against
the CURRENT world size. When the world auto-expands, stored world coords do
not move — only the normalization divisor grows — so ink stays put with zero
drift (proven by the world-expand-stability test).

- Replace InteractiveViewer+SizedBox+InkCanvas with PenCanvas (own pan/zoom,
  minScale 0.1 to survey the big world); keep the AnnotationToolbar.
- Stroke callbacks go through ink_stroke_adapter; load filters to freehand
  so the canvas list stays 1:1 with the undo manager.
- Pen/highlighter/eraser map from the toolbar's PenTool; width is world px.

The left PDF-reference pane (SfPdfViewer, read-only) is unchanged.

Tests: world-expand stability added. flutter analyze: 0. Suite: 270/270.
This commit is contained in:
2026-06-23 15:52:52 +08:00
parent ffb9e35755
commit 0ae2671f9b
2 changed files with 86 additions and 30 deletions

View File

@@ -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<SplitViewScreen> {
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();
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<SplitViewScreen> {
_saveTimer?.cancel();
_saveImmediate();
_pdfController.dispose();
_scratchTransform.dispose();
super.dispose();
}
@@ -87,8 +110,11 @@ class _SplitViewState extends State<SplitViewScreen> {
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<SplitViewScreen> {
// -- 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<SplitViewScreen> {
_scheduleSave();
}
void _onErase(String strokeId, List<InkStroke> 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<PenStroke> 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<SplitViewScreen> {
}
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),
),
);
}