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.
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
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
bool get _fingerDrawingEnabled =>
widget.allowFingerDrawing && !_stylusSeen;
// The explicit user toggle wins: if finger-drawing is ON, a single finger
// 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) =>
kind == PointerDeviceKind.stylus ||
@@ -112,9 +111,17 @@ class _PenCanvasState extends State<PenCanvas> {
/// usable pressure range (then perfect_freehand simulates pressure).
double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
if (event.pressureMin == event.pressureMax) return null;
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.
@@ -244,7 +251,6 @@ class _PenCanvasState extends State<PenCanvas> {
void _onPointerHover(PointerHoverEvent event) {
if (_isStylus(event.kind)) {
_stylusSeen = true;
// Detect eraser (barrel button / inverted) while hovering.
_eraserActive = _isEraserSignal(event);
}
@@ -252,7 +258,6 @@ class _PenCanvasState extends State<PenCanvas> {
void _onPointerDown(PointerDownEvent event) {
if (event.kind == PointerDeviceKind.trackpad) return;
if (_isStylus(event.kind)) _stylusSeen = true;
_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).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// One shared transform for the current page; reset on page change so each
/// page opens fit-to-view.
/// One shared transform for the current page; recentred on page change so
/// each page opens fit-to-view and centered.
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.
CanvasTool _tool = CanvasTool.pen;
Color _color = Colors.black;
@@ -82,7 +88,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _commitStroke(PenStroke stroke) {
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(() {
final list = _strokesByPage[_pageIndex];
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;
setState(() {
_pageIndex = clamped;
_transform.value = Matrix4.identity();
_needsCenter = true; // recenter the new page on next layout
});
}
Future<void> _promptJumpToPage() async {
final doc = _document;
if (doc == null) return;
final controller = TextEditingController(text: '${_pageIndex + 1}');
final result = await showDialog<int>(
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);
/// Centre [pageSize] within [viewport] via the shared transform.
void _centerPage(Size viewport, Size pageSize) {
final tx = (viewport.width - pageSize.width) / 2;
final ty = (viewport.height - pageSize.height) / 2;
_transform.value = Matrix4.identity()..setTranslationRaw(tx, ty, 0);
}
@override
@@ -206,8 +194,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
final scale = fit < fitH ? fit : fitH;
final pageSize = Size(page.width * scale, page.height * scale);
return Center(
child: PenCanvas(
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_pageIndex),
pageSize: pageSize,
strokes: _currentStrokes,
@@ -229,8 +224,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
decoration: const BoxDecoration(color: 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() {
final doc = _document!;
final cs = Theme.of(context).colorScheme;
final total = doc.pages.length;
final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round();
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed: _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
),
TextButton(
onPressed: _promptJumpToPage,
child: Text(
'${_pageIndex + 1} / ${doc.pages.length}',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
),
if (total > 1)
Flexible(
child: Slider(
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(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < doc.pages.length - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
],
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
],
),
),
),
);

View File

@@ -6,11 +6,15 @@
#include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_onnxruntime/flutter_onnxruntime_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

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

View File

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

View File

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

View File

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