fix(canvas): persist strokes, pressure, slider
All checks were successful
CI / Windows build (push) Successful in 8m33s
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
file_selector_linux
|
||||
flutter_onnxruntime
|
||||
url_launcher_linux
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
dynamic_color
|
||||
file_selector_windows
|
||||
flutter_onnxruntime
|
||||
syncfusion_pdfviewer_windows
|
||||
|
||||
Reference in New Issue
Block a user