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:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.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/ink_stroke.dart';
import '../models/pen_tool.dart'; import '../models/pen_tool.dart';
import '../models/pressure_curve.dart'; import '../models/pressure_curve.dart';
@@ -12,7 +16,6 @@ import '../services/database_service.dart';
import '../services/undo_manager.dart'; import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart'; import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart'; import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite /// Split-view derivation mode: left pane = reference PDF, right pane = infinite
/// scratchpad for formula derivation. Scratchpad strokes are persisted per /// scratchpad for formula derivation. Scratchpad strokes are persisted per
@@ -43,10 +46,29 @@ class _SplitViewState extends State<SplitViewScreen> {
bool _isDraggingDivider = false; bool _isDraggingDivider = false;
// -- Scratchpad (right pane) -- // -- 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(); final UndoManager _undoManager = UndoManager();
List<InkStroke> _strokes = []; List<InkStroke> _strokes = [];
double _canvasWidth = 4000; double _canvasWidth = 4000;
double _canvasHeight = 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 -- // -- Tool state --
PenTool _currentTool = PenTool.pen; PenTool _currentTool = PenTool.pen;
@@ -77,6 +99,7 @@ class _SplitViewState extends State<SplitViewScreen> {
_saveTimer?.cancel(); _saveTimer?.cancel();
_saveImmediate(); _saveImmediate();
_pdfController.dispose(); _pdfController.dispose();
_scratchTransform.dispose();
super.dispose(); super.dispose();
} }
@@ -87,8 +110,11 @@ class _SplitViewState extends State<SplitViewScreen> {
final strokes = await db.loadScratchpad(widget.documentId); final strokes = await db.loadScratchpad(widget.documentId);
if (mounted) { if (mounted) {
setState(() { setState(() {
_strokes = strokes; // Keep only freehand strokes so the canvas list stays 1:1 with the
for (final s in strokes) { // 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); _undoManager.addStroke(s);
} }
}); });
@@ -111,7 +137,11 @@ class _SplitViewState extends State<SplitViewScreen> {
// -- Scratchpad stroke callbacks -- // -- 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(() { setState(() {
_strokes.add(stroke); _strokes.add(stroke);
_undoManager.addStroke(stroke); _undoManager.addStroke(stroke);
@@ -120,13 +150,19 @@ class _SplitViewState extends State<SplitViewScreen> {
_scheduleSave(); _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(() { setState(() {
final original = _strokes.where((s) => s.id == strokeId).firstOrNull; final original = _strokes[index];
if (original != null) { final inkReplacements = [
_undoManager.removeStroke(original, replacements: replacements); for (final r in replacements)
inkStrokeFromPen(r, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now()),
];
_undoManager.removeStroke(original, replacements: inkReplacements);
_strokes = List.from(_undoManager.currentStrokes); _strokes = List.from(_undoManager.currentStrokes);
}
}); });
_scheduleSave(); _scheduleSave();
} }
@@ -432,29 +468,24 @@ class _SplitViewState extends State<SplitViewScreen> {
} }
Widget _buildScratchpadPane() { 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( return Container(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: InteractiveViewer( child: PenCanvas(
constrained: false, pageSize: _worldSize,
minScale: 0.25, strokes: penStrokesFromInk(_strokes, _worldSize),
maxScale: 8.0, transformationController: _scratchTransform,
boundaryMargin: const EdgeInsets.all(double.infinity), tool: _canvasTool,
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: InkCanvas(
strokes: _strokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor, color: _currentColor,
strokeWidth: _currentStrokeWidth, strokeWidth: _currentStrokeWidth / _canvasWidth,
pressureCurve: PressureCurve(type: _pressureCurveType), // The world is huge, so allow zooming further out to survey it.
stabilizationLevel: _stabilizationLevel, minScale: 0.1,
filled: _filled, maxScale: 8.0,
interactionMode: InteractionMode.draw, onStrokeComplete: _onStrokeComplete,
), onEraseStroke: _onErase,
), pageWidget: const ColoredBox(color: Colors.white),
), ),
); );
} }

View File

@@ -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', () { test('penStrokesFromInk preserves order and filters non-freehand', () {
final strokes = [ final strokes = [
ink([ip(0, 0), ip(1, 1)], tool: PenTool.pen, color: 0xFF000001), ink([ip(0, 0), ip(1, 1)], tool: PenTool.pen, color: 0xFF000001),