feat(note): rebuild note editor on the pen-first canvas
Some checks failed
CI / Windows build (push) Has been cancelled

Notes now use the single performant inking engine (PenCanvas) instead of
the old ink_canvas, per "all note features on the pen-first canvas".

- ink_stroke_adapter: pure InkStroke<->PenStroke bridge (normalize against
  a logical note page; drop non-freehand shapes/text). Round-trip tested.
- pen_palette_widgets: shared M3 ToolButton/PaletteDivider/RoundIconButton
  so PDF + note editors use identical chrome (PenEditorScreen migrated to
  them; its private copies deleted).
- PenNoteScreen: PenCanvas over a white logical page, undo/redo, title,
  save -> Note.strokes (+ local OCR for search). Pressure curve, eraser
  size/mode and palm rejection all inherited from the shared canvas.
- Route home (new/open) + search note hits -> PenNoteScreen; remove the
  now-redundant "Pen Canvas (beta)" spike button; delete the dead old
  note_editor_screen.

Tests: ink_stroke_adapter (5) + pen_note_screen widget (load + commit, 2).
flutter analyze: 0 issues. Full suite: 265/265.
This commit is contained in:
2026-06-23 10:21:40 +08:00
parent 3507e929b1
commit dfe5f2a477
12 changed files with 8769 additions and 418 deletions

View File

@@ -7,10 +7,9 @@ import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
import 'ppt_annotator_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
@@ -70,12 +69,6 @@ class HomeScreen extends ConsumerWidget {
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
// New pen-first canvas editor (beta).
IconButton(
icon: const Icon(Icons.draw_outlined),
tooltip: l.penCanvasBeta,
onPressed: () => openM1Spike(context),
),
],
),
floatingActionButton: FloatingActionButton(
@@ -196,7 +189,7 @@ class HomeScreen extends ConsumerWidget {
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
}
}
@@ -375,7 +368,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
);
},
onLongPress: () => _confirmDelete(context),
@@ -425,7 +418,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
if (!mounted) return;
if (result == 'open') {
Navigator.of(this.context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
);
} else if (result == 'delete') {
_confirmDelete(this.context);

View File

@@ -1,303 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
class NoteEditorScreen extends ConsumerStatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
final UndoManager _undoManager = UndoManager();
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
String _title = 'Untitled';
final TextEditingController _titleController = TextEditingController();
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _zoomController = TransformationController();
double _zoomLevel = 1.0;
bool _isDirty = false;
Note? get _existingNote => widget.note;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
@override
void initState() {
super.initState();
if (_existingNote != null) {
_title = _existingNote!.title;
for (final stroke in _existingNote!.strokes) {
_undoManager.addStroke(stroke);
}
}
_titleController.text = _title;
}
@override
void dispose() {
_titleController.dispose();
_zoomController.dispose();
super.dispose();
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_undoManager.addStroke(stroke);
_isDirty = true;
});
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _undoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_isDirty = true;
}
});
}
void _undo() {
setState(() {
_undoManager.undo();
_isDirty = true;
});
}
void _redo() {
setState(() {
_undoManager.redo();
_isDirty = true;
});
}
Future<void> _save() async {
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
Note savedNote;
if (_existingNote != null) {
final updated = _existingNote!.copyWith(
title: _title,
strokes: _undoManager.currentStrokes.toList(),
updatedAt: now,
);
await notifier.updateNote(updated);
savedNote = updated;
} else {
final note = await notifier.createNote(title: _title);
final updated = note.copyWith(
strokes: _undoManager.currentStrokes.toList(),
);
await notifier.updateNote(updated);
savedNote = updated;
}
if (!mounted) return;
setState(() {
_isDirty = false;
});
_runLocalOcr(savedNote);
}
/// Run local OCR and index results for search.
void _runLocalOcr(Note note) {
final noteId = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.processing,
};
ref
.read(ocrServiceProvider)
.processNote(note)
.then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.done,
};
})
.catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.failed,
};
});
}
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomReset() {
_applyZoom(1.0);
}
void _applyZoom(double level) {
setState(() => _zoomLevel = level);
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
}
Future<void> _saveAndNotify() async {
await _save();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Saved')));
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _isDirty) _save();
},
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveAndNotify,
},
child: Focus(
autofocus: true,
child: Scaffold(
appBar: AppBar(
title: SizedBox(
height: 40,
child: TextField(
controller: _titleController,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Note title...',
contentPadding: const EdgeInsets.symmetric(vertical: 8),
suffix: _isDirty
? const Text(
'',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
)
: null,
),
onChanged: (value) {
_title = value;
setState(() => _isDirty = true);
},
),
),
actions: [
IconButton(
icon: const Icon(Icons.check),
tooltip: 'Save',
onPressed: _saveAndNotify,
),
],
),
body: Column(
children: [
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,
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomReset,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: InteractiveViewer(
transformationController: _zoomController,
minScale: 0.5,
maxScale: 5.0,
child: InkCanvas(
strokes: _undoManager.currentStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -7,7 +7,7 @@ import '../editor/canvas/pen_editor_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@@ -179,7 +179,7 @@ class _NoteSearchResultTile extends StatelessWidget {
onTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
},
);
}