feat(split): scratchpad inks on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 20m37s
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:
@@ -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)
|
||||||
_strokes = List.from(_undoManager.currentStrokes);
|
inkStrokeFromPen(r, _worldSize,
|
||||||
}
|
id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
];
|
||||||
|
_undoManager.removeStroke(original, replacements: inkReplacements);
|
||||||
|
_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),
|
||||||
|
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,
|
maxScale: 8.0,
|
||||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
onStrokeComplete: _onStrokeComplete,
|
||||||
child: SizedBox(
|
onEraseStroke: _onErase,
|
||||||
width: _canvasWidth,
|
pageWidget: const ColoredBox(color: Colors.white),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user