fix(canvas): persist strokes, pressure, slider
All checks were successful
CI / Windows build (push) Successful in 8m33s

Strokes vanished on pen-up: StaticInkPainter aliased the same mutable
list so shouldRepaint saw no change. Commit/erase now replace the list.
Finger-drawing toggle wins over palm-rejection; pressure surfaces even
when the pen reports no min/max range; pages recenter after a flip; the
keyboard page-jump (unreliable on Windows) is now a drag slider. Also
register the dynamic_color plugin in generated registrants.
This commit is contained in:
2026-06-21 22:33:18 +08:00
parent 3febbd1431
commit 0c40a456e8
7 changed files with 109 additions and 76 deletions

View File

@@ -93,16 +93,15 @@ class _PenCanvasState extends State<PenCanvas> {
/// Live stroke snapshot handed to the LiveInkPainter; null when idle. /// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke; PenStroke? _liveStroke;
/// True once any stylus event is seen this session → finger-drawing forced
/// off so a resting palm pans instead of marking.
bool _stylusSeen = false;
/// True when the active stylus reports the eraser signal (barrel button or /// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down. /// inverted stylus), detected on hover/down.
bool _eraserActive = false; bool _eraserActive = false;
bool get _fingerDrawingEnabled => // The explicit user toggle wins: if finger-drawing is ON, a single finger
widget.allowFingerDrawing && !_stylusSeen; // draws even after a stylus has been seen. (Palm rejection when the toggle is
// OFF is automatic — fingers simply never draw — and a 2nd pointer always
// cancels an in-progress stroke regardless.)
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
bool _isStylus(PointerDeviceKind kind) => bool _isStylus(PointerDeviceKind kind) =>
kind == PointerDeviceKind.stylus || kind == PointerDeviceKind.stylus ||
@@ -112,9 +111,17 @@ class _PenCanvasState extends State<PenCanvas> {
/// usable pressure range (then perfect_freehand simulates pressure). /// usable pressure range (then perfect_freehand simulates pressure).
double? _normalizedPressure(PointerEvent event) { double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null; if (!_isStylus(event.kind)) return null;
if (event.pressureMin == event.pressureMax) return null;
final range = event.pressureMax - event.pressureMin; final range = event.pressureMax - event.pressureMin;
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
}
// No advertised range (some Windows pen stacks): use the raw normalized
// pressure directly if it's a usable non-degenerate value, so we still get
// real force instead of falling back to velocity simulation.
if (event.pressure > 0.0 && event.pressure < 1.0) {
return event.pressure;
}
return null;
} }
/// The eraser signal: barrel/secondary button held, or an inverted stylus. /// The eraser signal: barrel/secondary button held, or an inverted stylus.
@@ -244,7 +251,6 @@ class _PenCanvasState extends State<PenCanvas> {
void _onPointerHover(PointerHoverEvent event) { void _onPointerHover(PointerHoverEvent event) {
if (_isStylus(event.kind)) { if (_isStylus(event.kind)) {
_stylusSeen = true;
// Detect eraser (barrel button / inverted) while hovering. // Detect eraser (barrel button / inverted) while hovering.
_eraserActive = _isEraserSignal(event); _eraserActive = _isEraserSignal(event);
} }
@@ -252,7 +258,6 @@ class _PenCanvasState extends State<PenCanvas> {
void _onPointerDown(PointerDownEvent event) { void _onPointerDown(PointerDownEvent event) {
if (event.kind == PointerDeviceKind.trackpad) return; if (event.kind == PointerDeviceKind.trackpad) return;
if (_isStylus(event.kind)) _stylusSeen = true;
_activePointers[event.pointer] = event.kind; _activePointers[event.pointer] = event.kind;

View File

@@ -30,10 +30,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// Strokes per page, keyed by 0-based page index (normalized coords). /// Strokes per page, keyed by 0-based page index (normalized coords).
final Map<int, List<PenStroke>> _strokesByPage = {}; final Map<int, List<PenStroke>> _strokesByPage = {};
/// One shared transform for the current page; reset on page change so each /// One shared transform for the current page; recentred on page change so
/// page opens fit-to-view. /// each page opens fit-to-view and centered.
final TransformationController _transform = TransformationController(); final TransformationController _transform = TransformationController();
/// Set when the page must be (re)centered on the next layout pass.
bool _needsCenter = true;
/// Live page value while dragging the page slider (null when not dragging).
double? _scrub;
// Tool state. // Tool state.
CanvasTool _tool = CanvasTool.pen; CanvasTool _tool = CanvasTool.pen;
Color _color = Colors.black; Color _color = Colors.black;
@@ -82,7 +88,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _commitStroke(PenStroke stroke) { void _commitStroke(PenStroke stroke) {
setState(() { setState(() {
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]).add(stroke); // Replace with a NEW list so StaticInkPainter sees a fresh identity and
// actually repaints (mutating in place would alias the old painter's list
// and shouldRepaint would see no change → committed strokes vanish).
_strokesByPage[_pageIndex] = [
...?_strokesByPage[_pageIndex],
stroke,
];
}); });
} }
@@ -90,7 +102,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() { setState(() {
final list = _strokesByPage[_pageIndex]; final list = _strokesByPage[_pageIndex];
if (list != null && index >= 0 && index < list.length) { if (list != null && index >= 0 && index < list.length) {
list.removeAt(index); final next = List<PenStroke>.of(list)..removeAt(index);
_strokesByPage[_pageIndex] = next;
} }
}); });
} }
@@ -102,40 +115,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (clamped == _pageIndex) return; if (clamped == _pageIndex) return;
setState(() { setState(() {
_pageIndex = clamped; _pageIndex = clamped;
_transform.value = Matrix4.identity(); _needsCenter = true; // recenter the new page on next layout
}); });
} }
Future<void> _promptJumpToPage() async { /// Centre [pageSize] within [viewport] via the shared transform.
final doc = _document; void _centerPage(Size viewport, Size pageSize) {
if (doc == null) return; final tx = (viewport.width - pageSize.width) / 2;
final controller = TextEditingController(text: '${_pageIndex + 1}'); final ty = (viewport.height - pageSize.height) / 2;
final result = await showDialog<int>( _transform.value = Matrix4.identity()..setTranslationRaw(tx, ty, 0);
context: context,
builder: (context) => AlertDialog(
title: const Text('Go to page'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(hintText: '1 ${doc.pages.length}'),
onSubmitted: (v) =>
Navigator.of(context).pop(int.tryParse(v.trim())),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(int.tryParse(controller.text.trim())),
child: const Text('Go'),
),
],
),
);
if (result != null) _goToPage(result - 1);
} }
@override @override
@@ -206,8 +194,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
final scale = fit < fitH ? fit : fitH; final scale = fit < fitH ? fit : fitH;
final pageSize = Size(page.width * scale, page.height * scale); final pageSize = Size(page.width * scale, page.height * scale);
return Center( if (_needsCenter) {
child: PenCanvas( WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_pageIndex), key: ValueKey(_pageIndex),
pageSize: pageSize, pageSize: pageSize,
strokes: _currentStrokes, strokes: _currentStrokes,
@@ -229,8 +224,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
decoration: const BoxDecoration(color: Colors.white), decoration: const BoxDecoration(color: Colors.white),
backgroundColor: Colors.white, backgroundColor: Colors.white,
), ),
), );
);
}, },
); );
} }
@@ -305,42 +299,65 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
); );
} }
/// Floating page-control pill: prev / "n / total" / next. /// Floating page-control pill: prev / drag-slider / next. No keyboard input
/// (Windows on-screen keyboard is unreliable) — the slider scrubs pages.
Widget _buildPagePill() { Widget _buildPagePill() {
final doc = _document!; final doc = _document!;
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final total = doc.pages.length;
final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round();
return Material( return Material(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
elevation: 3, elevation: 3,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
child: Padding( child: ConstrainedBox(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), constraints: const BoxConstraints(maxWidth: 560),
child: Row( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
children: [ child: Row(
IconButton( mainAxisSize: MainAxisSize.min,
tooltip: 'Previous page', children: [
icon: const Icon(Icons.chevron_left), IconButton(
onPressed: _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null, tooltip: 'Previous page',
), icon: const Icon(Icons.chevron_left),
TextButton( onPressed:
onPressed: _promptJumpToPage, _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
child: Text( ),
'${_pageIndex + 1} / ${doc.pages.length}', if (total > 1)
style: TextStyle( Flexible(
color: cs.onSurface, child: Slider(
fontWeight: FontWeight.w600, min: 1,
max: total.toDouble(),
value: (_scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
label: '$shown',
divisions: total - 1,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
setState(() => _scrub = null);
_goToPage(v.round() - 1);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'$shown / $total',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
), ),
), ),
), IconButton(
IconButton( tooltip: 'Next page',
tooltip: 'Next page', icon: const Icon(Icons.chevron_right),
icon: const Icon(Icons.chevron_right), onPressed: _pageIndex < total - 1
onPressed: _pageIndex < doc.pages.length - 1 ? () => _goToPage(_pageIndex + 1)
? () => _goToPage(_pageIndex + 1) : null,
: null, ),
), ],
], ),
), ),
), ),
); );

View File

@@ -6,11 +6,15 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_onnxruntime/flutter_onnxruntime_plugin.h> #include <flutter_onnxruntime/flutter_onnxruntime_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_selector_linux file_selector_linux
flutter_onnxruntime flutter_onnxruntime
url_launcher_linux url_launcher_linux

View File

@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation import Foundation
import device_info_plus import device_info_plus
import dynamic_color
import file_picker import file_picker
import file_selector_macos import file_selector_macos
import flutter_onnxruntime import flutter_onnxruntime
@@ -16,6 +17,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterOnnxruntimePlugin.register(with: registry.registrar(forPlugin: "FlutterOnnxruntimePlugin")) FlutterOnnxruntimePlugin.register(with: registry.registrar(forPlugin: "FlutterOnnxruntimePlugin"))

View File

@@ -6,12 +6,15 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_onnxruntime/flutter_onnxruntime_plugin.h> #include <flutter_onnxruntime/flutter_onnxruntime_plugin.h>
#include <syncfusion_pdfviewer_windows/syncfusion_pdfviewer_windows_plugin.h> #include <syncfusion_pdfviewer_windows/syncfusion_pdfviewer_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterOnnxruntimePluginRegisterWithRegistrar( FlutterOnnxruntimePluginRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_selector_windows file_selector_windows
flutter_onnxruntime flutter_onnxruntime
syncfusion_pdfviewer_windows syncfusion_pdfviewer_windows