fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
All checks were successful
CI / Windows build (push) Successful in 8m19s
All checks were successful
CI / Windows build (push) Successful in 8m19s
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape strokes, expand the ink palette, and replace scratch-link split view with an on-page sticky that shares the sidecar repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -324,7 +324,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
|
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
|
||||||
|
///
|
||||||
|
/// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's
|
||||||
|
/// PointerEvent.pressure on Windows is often flat/useless while the driver
|
||||||
|
/// still reports real 0..1024 via GetPointerPenInfo.
|
||||||
double? _rawNormalizedPressure(PointerEvent event) {
|
double? _rawNormalizedPressure(PointerEvent event) {
|
||||||
|
final hw = PenInputService.instance;
|
||||||
|
if (hw.isActive && hw.current.pressureValid) {
|
||||||
|
return hw.current.pressure.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
final range = event.pressureMax - event.pressureMin;
|
final range = event.pressureMax - event.pressureMin;
|
||||||
if (range > 0.0001) {
|
if (range > 0.0001) {
|
||||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||||
@@ -560,7 +568,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: PenStrokeKind.pen,
|
kind: PenStrokeKind.pen,
|
||||||
brush: _currentBrush,
|
brush: kShapeBrush,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if (tool == CanvasTool.select) {
|
} else if (tool == CanvasTool.select) {
|
||||||
@@ -619,7 +627,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: PenStrokeKind.pen,
|
kind: PenStrokeKind.pen,
|
||||||
brush: _currentBrush,
|
brush: kShapeBrush,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/bookmark.dart';
|
import '../../models/bookmark.dart';
|
||||||
import '../../models/scratch_link.dart';
|
import '../../models/scratch_link.dart';
|
||||||
import '../../screens/split_view_screen.dart';
|
|
||||||
import '../../storage/badnote_sidecar.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
import '../engine/shape_geometry.dart';
|
import '../engine/shape_geometry.dart';
|
||||||
@@ -56,6 +55,7 @@ import 'input_diagnostics.dart';
|
|||||||
import 'pen_palette_widgets.dart';
|
import 'pen_palette_widgets.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
import 'pinch_scale_solver.dart';
|
import 'pinch_scale_solver.dart';
|
||||||
|
import 'sticky_note_overlay.dart';
|
||||||
|
|
||||||
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
|
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
|
||||||
/// (not scaled with zoom) so the tap target stays comfortably tappable.
|
/// (not scaled with zoom) so the tap target stays comfortably tappable.
|
||||||
@@ -154,11 +154,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
/// A real pinch changes scale modestly per frame; a frame demanding far more
|
/// A real pinch changes scale modestly per frame; a frame demanding far more
|
||||||
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
|
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
|
||||||
static const double _kScaleGlitchHi = 1.4;
|
/// Logs showed ~1.30 spikes — keep the band below that.
|
||||||
|
static const double _kScaleGlitchHi = 1.18;
|
||||||
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
||||||
|
|
||||||
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
||||||
static const double _kFocalGlitchPx = 250.0;
|
static const double _kFocalGlitchPx = 100.0;
|
||||||
|
|
||||||
/// Matrix scale captured at the current baseline (gesture start or the last
|
/// Matrix scale captured at the current baseline (gesture start or the last
|
||||||
/// pointer-count re-baseline). Null when no pinch is active.
|
/// pointer-count re-baseline). Null when no pinch is active.
|
||||||
@@ -247,9 +248,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
? BrushKind.highlighter
|
? BrushKind.highlighter
|
||||||
: _penBrush;
|
: _penBrush;
|
||||||
|
|
||||||
/// When true the "select text" tool is active: pen capture is disabled so the
|
/// When true the "select text" tool button is latched on.
|
||||||
/// pen falls through to pdfrx for native text selection.
|
bool _selectTextTool = false;
|
||||||
bool _selectTextMode = false;
|
|
||||||
|
/// Barrel-held temporary select-text (OneNote-style); ORed into [_selectTextMode].
|
||||||
|
bool _barrelSelectText = false;
|
||||||
|
|
||||||
|
/// Select-text is active from the tool button OR a held barrel mapped to
|
||||||
|
/// [PenButtonAction.selectText].
|
||||||
|
bool get _selectTextMode => _selectTextTool || _barrelSelectText;
|
||||||
|
|
||||||
|
/// Expanded paper-sticky overlay for a scratch link (null = collapsed).
|
||||||
|
ScratchLink? _expandedSticky;
|
||||||
|
|
||||||
/// When true the "place scratch link" tool is active: a tap on a page drops a
|
/// When true the "place scratch link" tool is active: a tap on a page drops a
|
||||||
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
|
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
|
||||||
@@ -290,19 +300,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
static const double _penWidthFraction = 0.006;
|
static const double _penWidthFraction = 0.006;
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
static const List<Color> _palette = [
|
static const List<Color> _palette = kInkPalette;
|
||||||
Colors.black,
|
|
||||||
Colors.red,
|
|
||||||
Colors.blue,
|
|
||||||
Colors.green,
|
|
||||||
Colors.orange,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
|
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
|
||||||
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
|
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
|
||||||
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
|
/// selection), place-link / sticky expanded, and text mode.
|
||||||
bool get _penCaptureEnabled =>
|
bool get _penCaptureEnabled =>
|
||||||
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode && !_textMode;
|
!_selectTextMode &&
|
||||||
|
!_placeLinkMode &&
|
||||||
|
!_removeHighlightMode &&
|
||||||
|
!_textMode &&
|
||||||
|
_expandedSticky == null;
|
||||||
|
|
||||||
/// True when the eraser tool is active.
|
/// True when the eraser tool is active.
|
||||||
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
||||||
@@ -320,6 +328,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
||||||
// No-op on platforms without the plugin (W3).
|
// No-op on platforms without the plugin (W3).
|
||||||
PenInputService.instance.start();
|
PenInputService.instance.start();
|
||||||
|
PenInputService.instance.addListener(_onHwPenChanged);
|
||||||
_initPersistence();
|
_initPersistence();
|
||||||
_initPenConfig();
|
_initPenConfig();
|
||||||
}
|
}
|
||||||
@@ -339,6 +348,34 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
void _onPenConfigChanged() {
|
void _onPenConfigChanged() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
|
_syncBarrelSelectText();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onHwPenChanged() {
|
||||||
|
_syncBarrelSelectText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level-trigger: holding barrel with sideButton=selectText enables text
|
||||||
|
/// selection without latching the toolbar tool.
|
||||||
|
void _syncBarrelSelectText() {
|
||||||
|
final cfg = _penConfig?.value;
|
||||||
|
final hw = PenInputService.instance;
|
||||||
|
final want = cfg != null &&
|
||||||
|
hw.isActive &&
|
||||||
|
hw.current.barrel &&
|
||||||
|
cfg.sideButton == PenButtonAction.selectText;
|
||||||
|
if (want == _barrelSelectText) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_barrelSelectText = want;
|
||||||
|
if (want) {
|
||||||
|
_placeLinkMode = false;
|
||||||
|
_removeHighlightMode = false;
|
||||||
|
_textMode = false;
|
||||||
|
_selected = null;
|
||||||
|
_expandedSticky = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initPersistence() async {
|
Future<void> _initPersistence() async {
|
||||||
@@ -412,7 +449,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
_overlayRepaint.dispose();
|
_overlayRepaint.dispose();
|
||||||
_liveStrokeVN.dispose();
|
_liveStrokeVN.dispose();
|
||||||
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
_penConfig?.dispose();
|
_penConfig?.dispose();
|
||||||
|
PenInputService.instance.removeListener(_onHwPenChanged);
|
||||||
PenInputService.instance.stop();
|
PenInputService.instance.stop();
|
||||||
DiagnosticLogger.instance.stop();
|
DiagnosticLogger.instance.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -509,6 +548,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double? _rawNormalizedPressure(PointerEvent event) {
|
double? _rawNormalizedPressure(PointerEvent event) {
|
||||||
|
final hw = PenInputService.instance;
|
||||||
|
if (hw.isActive && hw.current.pressureValid) {
|
||||||
|
return hw.current.pressure.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
final range = event.pressureMax - event.pressureMin;
|
final range = event.pressureMax - event.pressureMin;
|
||||||
if (range > 0.0001) {
|
if (range > 0.0001) {
|
||||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||||
@@ -538,6 +581,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hit-test scratch-link markers near [normalized] on [page].
|
||||||
|
ScratchLink? _hitScratchMarker(int page, Offset normalized) {
|
||||||
|
const r = 0.045;
|
||||||
|
final r2 = r * r;
|
||||||
|
for (final link in _scratchLinks) {
|
||||||
|
if (link.pageIndex != page) continue;
|
||||||
|
final dx = link.nx - normalized.dx;
|
||||||
|
final dy = link.ny - normalized.dy;
|
||||||
|
if (dx * dx + dy * dy <= r2) return link;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _onPenEvent(PointerEvent event) {
|
void _onPenEvent(PointerEvent event) {
|
||||||
if (_isStylus(event.kind)) _emitPenDebug(event);
|
if (_isStylus(event.kind)) _emitPenDebug(event);
|
||||||
|
|
||||||
@@ -545,6 +601,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
if (event is PointerDownEvent) {
|
if (event is PointerDownEvent) {
|
||||||
if (hit == null) return;
|
if (hit == null) return;
|
||||||
|
// Stylus can open sticky markers (PenCaptureRegion otherwise steals taps).
|
||||||
|
final sticky = _hitScratchMarker(hit.page, hit.normalized);
|
||||||
|
if (sticky != null) {
|
||||||
|
_openScratchLink(sticky);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_isEraser) {
|
if (_isEraser) {
|
||||||
_liveStrokePage = hit.page;
|
_liveStrokePage = hit.page;
|
||||||
_eraseAt(hit.page, hit.normalized);
|
_eraseAt(hit.page, hit.normalized);
|
||||||
@@ -687,7 +749,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: _currentStrokeWidth(),
|
width: _currentStrokeWidth(),
|
||||||
kind: PenStrokeKind.pen,
|
kind: PenStrokeKind.pen,
|
||||||
brush: _currentBrush(),
|
brush: kShapeBrush,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -847,7 +909,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
final scaleDrop =
|
final scaleDrop =
|
||||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
||||||
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
||||||
if (scaleDrop || focalDrop) return;
|
if (scaleDrop || focalDrop) {
|
||||||
|
InputDiagnostics.instance.recordScaleFrame(
|
||||||
|
rawScale: details.scale,
|
||||||
|
pointerCount: details.pointerCount,
|
||||||
|
currentScale: _pinchLastAppliedScale,
|
||||||
|
appliedChange: 1.0,
|
||||||
|
focalJumpPx: details.focalPointDelta.distance,
|
||||||
|
scaleDrop: scaleDrop,
|
||||||
|
focalDrop: focalDrop,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final targetScale = absolutePinchScale(
|
final targetScale = absolutePinchScale(
|
||||||
scaleStart: _pinchScaleStart!,
|
scaleStart: _pinchScaleStart!,
|
||||||
@@ -866,6 +939,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
duration: Duration.zero,
|
duration: Duration.zero,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final applied =
|
||||||
|
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
|
||||||
|
InputDiagnostics.instance.recordScaleFrame(
|
||||||
|
rawScale: details.scale,
|
||||||
|
pointerCount: details.pointerCount,
|
||||||
|
currentScale: targetScale,
|
||||||
|
appliedChange: applied,
|
||||||
|
focalJumpPx: details.focalPointDelta.distance,
|
||||||
|
scaleDrop: false,
|
||||||
|
focalDrop: false,
|
||||||
|
);
|
||||||
|
|
||||||
_pinchLastRawScale = details.scale;
|
_pinchLastRawScale = details.scale;
|
||||||
_pinchLastAppliedScale = targetScale;
|
_pinchLastAppliedScale = targetScale;
|
||||||
}
|
}
|
||||||
@@ -1071,7 +1156,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
void _setTool(EditorToolKind tool) {
|
void _setTool(EditorToolKind tool) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tool = tool;
|
_tool = tool;
|
||||||
_selectTextMode = false;
|
_selectTextTool = false;
|
||||||
_placeLinkMode = false;
|
_placeLinkMode = false;
|
||||||
_removeHighlightMode = false;
|
_removeHighlightMode = false;
|
||||||
// The TEXT tool is the one EditorToolKind that drives a page-anchored
|
// The TEXT tool is the one EditorToolKind that drives a page-anchored
|
||||||
@@ -1083,11 +1168,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
void _enableSelectText() {
|
void _enableSelectText() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectTextMode = true;
|
_selectTextTool = true;
|
||||||
_placeLinkMode = false;
|
_placeLinkMode = false;
|
||||||
_removeHighlightMode = false;
|
_removeHighlightMode = false;
|
||||||
_textMode = false;
|
_textMode = false;
|
||||||
_selected = null;
|
_selected = null;
|
||||||
|
_expandedSticky = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,9 +1183,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_placeLinkMode = !_placeLinkMode;
|
_placeLinkMode = !_placeLinkMode;
|
||||||
if (_placeLinkMode) {
|
if (_placeLinkMode) {
|
||||||
_selectTextMode = false;
|
_selectTextTool = false;
|
||||||
_removeHighlightMode = false;
|
_removeHighlightMode = false;
|
||||||
_textMode = false;
|
_textMode = false;
|
||||||
|
_expandedSticky = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1110,10 +1197,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_removeHighlightMode = !_removeHighlightMode;
|
_removeHighlightMode = !_removeHighlightMode;
|
||||||
if (_removeHighlightMode) {
|
if (_removeHighlightMode) {
|
||||||
_selectTextMode = false;
|
_selectTextTool = false;
|
||||||
_placeLinkMode = false;
|
_placeLinkMode = false;
|
||||||
_textMode = false;
|
_textMode = false;
|
||||||
_selected = null;
|
_selected = null;
|
||||||
|
_expandedSticky = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1138,35 +1226,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() => _scratchLinks.add(link));
|
setState(() => _scratchLinks.add(link));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the anchor's split view (left = this PDF at the anchor page, right =
|
/// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME
|
||||||
/// the anchor's private infinite scratchpad, stored in the sidecar).
|
/// [SidecarRepository] — no second open, no split-view race.
|
||||||
///
|
|
||||||
/// Flushes any pending sidecar write FIRST so the split view (which opens its
|
|
||||||
/// own [SidecarRepository] on the same file) sees this anchor on disk before
|
|
||||||
/// it writes the scratchpad back. On return, reload so any scratchpad change
|
|
||||||
/// made there is reflected in this editor's in-memory sidecar repo.
|
|
||||||
Future<void> _openScratchLink(ScratchLink link) async {
|
Future<void> _openScratchLink(ScratchLink link) async {
|
||||||
await _repo?.flush();
|
setState(() {
|
||||||
|
_expandedSticky = link;
|
||||||
|
_selectTextTool = false;
|
||||||
|
_placeLinkMode = false;
|
||||||
|
_removeHighlightMode = false;
|
||||||
|
_textMode = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _closeSticky() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await Navigator.of(context).push(
|
setState(() => _expandedSticky = null);
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => SplitViewScreen(
|
|
||||||
filePath: widget.pdfPath,
|
|
||||||
scratchLinkId: link.id,
|
|
||||||
initialPage: link.pageIndex,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
// The split view wrote the scratchpad into the on-disk sidecar via its own
|
|
||||||
// repo; re-open ours so subsequent saves here don't clobber that scratchpad.
|
|
||||||
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
|
|
||||||
if (!mounted) {
|
|
||||||
repo.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_repo?.dispose();
|
|
||||||
_repo = repo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm + delete an anchor (and its private scratchpad).
|
/// Confirm + delete an anchor (and its private scratchpad).
|
||||||
@@ -1694,6 +1768,32 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: const IgnorePointer(child: SizedBox.expand()),
|
child: const IgnorePointer(child: SizedBox.expand()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_hasSelection)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 24,
|
||||||
|
child: _SelectionActionBar(
|
||||||
|
onHighlight: _highlightSelection,
|
||||||
|
onBookmark: _addBookmark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_expandedSticky != null && _repo != null)
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
top: 72,
|
||||||
|
child: StickyNoteOverlay(
|
||||||
|
key: ValueKey(_expandedSticky!.id),
|
||||||
|
link: _expandedSticky!,
|
||||||
|
repo: _repo!,
|
||||||
|
onClose: _closeSticky,
|
||||||
|
onDelete: () async {
|
||||||
|
final link = _expandedSticky!;
|
||||||
|
_closeSticky();
|
||||||
|
await _confirmDeleteScratchLink(link);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -2221,8 +2321,50 @@ class LiveStrokeOverlayHarness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Floating action bar shown while PDF text is selected (OneNote-style).
|
||||||
|
class _SelectionActionBar extends StatelessWidget {
|
||||||
|
const _SelectionActionBar({
|
||||||
|
required this.onHighlight,
|
||||||
|
required this.onBookmark,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onHighlight;
|
||||||
|
final VoidCallback onBookmark;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Material(
|
||||||
|
elevation: 4,
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onHighlight,
|
||||||
|
icon: const Icon(Icons.highlight, size: 18),
|
||||||
|
label: Text(l.actionHighlightSelection),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onBookmark,
|
||||||
|
icon: const Icon(Icons.bookmark_add_outlined, size: 18),
|
||||||
|
label: Text(l.toolAddBookmark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
|
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
|
||||||
/// Tap opens the anchor's split view; long-press deletes the anchor.
|
/// Tap opens the sticky overlay; long-press deletes the anchor.
|
||||||
class _ScratchLinkMarker extends StatelessWidget {
|
class _ScratchLinkMarker extends StatelessWidget {
|
||||||
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
|
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
|
||||||
|
|
||||||
@@ -2335,9 +2477,9 @@ class _TextAnnotationLabel extends StatelessWidget {
|
|||||||
|
|
||||||
/// The active editing field for a text box. A REAL Flutter [TextField] so the
|
/// The active editing field for a text box. A REAL Flutter [TextField] so the
|
||||||
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
|
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
|
||||||
/// automatically (no special plugin; a focusable text input is all the panel
|
/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop
|
||||||
/// needs). Autofocuses on insert; commits via [onChanged] (debounced persist)
|
/// the soft keyboard on every pen tap that places a box); focus only after an
|
||||||
/// and finishes via [onDone] (submit / focus loss).
|
/// explicit tap on the field.
|
||||||
class _TextAnnotationField extends StatefulWidget {
|
class _TextAnnotationField extends StatefulWidget {
|
||||||
const _TextAnnotationField({
|
const _TextAnnotationField({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -2370,11 +2512,6 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
|||||||
_controller = TextEditingController(text: widget.initialText);
|
_controller = TextEditingController(text: widget.initialText);
|
||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
_focusNode.addListener(_onFocusChange);
|
_focusNode.addListener(_onFocusChange);
|
||||||
// Autofocus after the first frame so the field is mounted before we request
|
|
||||||
// focus (which also raises the IME / handwriting panel).
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) _focusNode.requestFocus();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFocusChange() {
|
void _onFocusChange() {
|
||||||
@@ -2399,7 +2536,7 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: false,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
|
|||||||
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
|
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
|
||||||
/// demanding far more than this is a Windows multi-touch position glitch, not
|
/// demanding far more than this is a Windows multi-touch position glitch, not
|
||||||
/// intent — that frame is dropped so the zoom can't pop and snap back.
|
/// intent — that frame is dropped so the zoom can't pop and snap back.
|
||||||
const double _kScaleGlitchHi = 1.4;
|
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
|
||||||
|
const double _kScaleGlitchHi = 1.18;
|
||||||
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
||||||
|
|
||||||
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
||||||
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
|
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
|
||||||
/// and the frame is dropped (position-jump guard).
|
/// and the frame is dropped (position-jump guard).
|
||||||
const double _kFocalGlitchPx = 250.0;
|
const double _kFocalGlitchPx = 100.0;
|
||||||
|
|
||||||
const double _kDrag = 0.0000135;
|
const double _kDrag = 0.0000135;
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
static const double _penWidthFraction = 0.006;
|
static const double _penWidthFraction = 0.006;
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
static const List<Color> _palette = [
|
static const List<Color> _palette = kInkPalette;
|
||||||
Colors.black,
|
|
||||||
Colors.red,
|
|
||||||
Colors.blue,
|
|
||||||
Colors.green,
|
|
||||||
Colors.orange,
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
|
|
||||||
|
/// Shared ink color palette for PDF / note / slide / scratch editors.
|
||||||
|
const List<Color> kInkPalette = <Color>[
|
||||||
|
Color(0xFF1A1A1A),
|
||||||
|
Color(0xFFC62828),
|
||||||
|
Color(0xFF1565C0),
|
||||||
|
Color(0xFF2E7D32),
|
||||||
|
Color(0xFFEF6C00),
|
||||||
|
Color(0xFF6A1B9A),
|
||||||
|
Color(0xFF00838F),
|
||||||
|
Color(0xFF5D4037),
|
||||||
|
Color(0xFFF9A825),
|
||||||
|
Color(0xFFE91E63),
|
||||||
|
Color(0xFF455A64),
|
||||||
|
Color(0xFF37474F),
|
||||||
|
];
|
||||||
|
|
||||||
/// Localized display name for a brush (single source so all three editors agree).
|
/// Localized display name for a brush (single source so all three editors agree).
|
||||||
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
|
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
|
||||||
BrushKind.fountainPen => l.brushFountainPen,
|
BrushKind.fountainPen => l.brushFountainPen,
|
||||||
|
|||||||
@@ -93,13 +93,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
static const Size _fallbackSlide = Size(1600, 900);
|
static const Size _fallbackSlide = Size(1600, 900);
|
||||||
|
|
||||||
static const List<Color> _palette = [
|
static const List<Color> _palette = kInkPalette;
|
||||||
Colors.black,
|
|
||||||
Colors.red,
|
|
||||||
Colors.blue,
|
|
||||||
Colors.green,
|
|
||||||
Colors.orange,
|
|
||||||
];
|
|
||||||
|
|
||||||
int get _slideCount => widget.slideImagePaths.length;
|
int get _slideCount => widget.slideImagePaths.length;
|
||||||
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
||||||
|
|||||||
263
lib/editor/canvas/sticky_note_overlay.dart
Normal file
263
lib/editor/canvas/sticky_note_overlay.dart
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// lib/editor/canvas/sticky_note_overlay.dart
|
||||||
|
//
|
||||||
|
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
|
||||||
|
// inks into the SAME SidecarRepository the PDF editor holds (no second open,
|
||||||
|
// no split-view race). Collapsed = page marker; expanded = this overlay.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../models/ink_stroke.dart';
|
||||||
|
import '../../models/scratch_link.dart';
|
||||||
|
import '../../storage/badnote_sidecar.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../input/pen_config.dart' show kDefaultEraserRadius;
|
||||||
|
import '../notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../persistence/sidecar_repository.dart';
|
||||||
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_palette_widgets.dart';
|
||||||
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
/// Default world size for a fresh sticky scratchpad (absolute px).
|
||||||
|
const Size kStickyWorldSize = Size(1200, 900);
|
||||||
|
|
||||||
|
/// Floating sticky-note card: write → autosave into [repo] under [link.id].
|
||||||
|
class StickyNoteOverlay extends StatefulWidget {
|
||||||
|
const StickyNoteOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.link,
|
||||||
|
required this.repo,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ScratchLink link;
|
||||||
|
final SidecarRepository repo;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
List<InkStroke> _strokes = [];
|
||||||
|
Size _world = kStickyWorldSize;
|
||||||
|
CanvasTool _tool = CanvasTool.pen;
|
||||||
|
BrushKind _brush = BrushKind.ballpoint; // ignore: prefer_final_fields — reserved for brush picker
|
||||||
|
Color _color = kInkPalette.first;
|
||||||
|
Timer? _saveTimer;
|
||||||
|
bool _dirty = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final pad = widget.repo.scratchpadFor(widget.link.id);
|
||||||
|
if (pad != null) {
|
||||||
|
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
||||||
|
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
// Best-effort sync save before leaving the overlay.
|
||||||
|
if (_dirty) {
|
||||||
|
widget.repo.scheduleScratchpadSave(
|
||||||
|
widget.link.id,
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: _world.width,
|
||||||
|
canvasHeight: _world.height,
|
||||||
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.repo.flush();
|
||||||
|
}
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSave() {
|
||||||
|
_dirty = true;
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
_saveTimer = Timer(const Duration(milliseconds: 600), _saveNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveNow() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
widget.repo.scheduleScratchpadSave(
|
||||||
|
widget.link.id,
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: _world.width,
|
||||||
|
canvasHeight: _world.height,
|
||||||
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_dirty = false;
|
||||||
|
await widget.repo.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStrokeComplete(PenStroke pen) {
|
||||||
|
setState(() {
|
||||||
|
_strokes = [
|
||||||
|
..._strokes,
|
||||||
|
inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onErase(int index, List<PenStroke> replacements) {
|
||||||
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
final next = List<InkStroke>.of(_strokes)..removeAt(index);
|
||||||
|
for (final r in replacements) {
|
||||||
|
next.insert(
|
||||||
|
index,
|
||||||
|
inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_strokes = next;
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _close() async {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
await _saveNow();
|
||||||
|
widget.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
elevation: 8,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: const Color(0xFFFFF8E1),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 280,
|
||||||
|
height: 340,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFFE082),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sticky_note_2, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'便利贴',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '删除',
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () async {
|
||||||
|
await _saveNow();
|
||||||
|
widget.onDelete();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '收起',
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: _close,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
children: [
|
||||||
|
for (final c in kInkPalette)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _color = c),
|
||||||
|
child: Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 3, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: _color == c ? cs.primary : cs.outlineVariant,
|
||||||
|
width: _color == c ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRect(
|
||||||
|
child: PenCanvas(
|
||||||
|
pageSize: _world,
|
||||||
|
strokes: penStrokesFromInk(_strokes, _world),
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: _tool,
|
||||||
|
brush: _brush,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: 0.008,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
|
minScale: 0.2,
|
||||||
|
maxScale: 4.0,
|
||||||
|
onStrokeComplete: _onStrokeComplete,
|
||||||
|
onEraseStroke: _onErase,
|
||||||
|
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: '笔',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.edit,
|
||||||
|
size: 18,
|
||||||
|
color: _tool == CanvasTool.pen ? cs.primary : null,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '橡皮',
|
||||||
|
icon: Icon(
|
||||||
|
Icons.cleaning_services_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: _tool == CanvasTool.eraser ? cs.primary : null,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,13 +142,15 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|||||||
kind: BrushKind.fountainPen,
|
kind: BrushKind.fountainPen,
|
||||||
baseWidthFraction: 0.006,
|
baseWidthFraction: 0.006,
|
||||||
pressureGamma: 2.0,
|
pressureGamma: 2.0,
|
||||||
pfThinning: 0.9,
|
// Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph).
|
||||||
pfStreamline: 0.45,
|
pfThinning: 0.65,
|
||||||
pfSmoothing: 0.55,
|
pfStreamline: 0.4,
|
||||||
|
pfSmoothing: 0.5,
|
||||||
simulatePressure: false,
|
simulatePressure: false,
|
||||||
capStart: true,
|
capStart: true,
|
||||||
capEnd: true,
|
capEnd: true,
|
||||||
taper: true,
|
// Light taper only; full taper made Chinese characters look frayed.
|
||||||
|
taper: false,
|
||||||
opacity: 1.0,
|
opacity: 1.0,
|
||||||
blendMultiply: false,
|
blendMultiply: false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import '../canvas/editor_tool.dart';
|
import '../canvas/editor_tool.dart';
|
||||||
import '../canvas/pen_stroke.dart';
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
|
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
|
||||||
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
|
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
|
||||||
@@ -23,6 +24,10 @@ const int kEllipseSamples = 48;
|
|||||||
/// width (no pressure taper for geometric shapes).
|
/// width (no pressure taper for geometric shapes).
|
||||||
const double _kShapePressure = 1.0;
|
const double _kShapePressure = 1.0;
|
||||||
|
|
||||||
|
/// Geometric shapes must NOT inherit fountain thinning/taper — force a near-
|
||||||
|
/// constant-width brush so line/rect/ellipse look like ruler ink.
|
||||||
|
const BrushKind kShapeBrush = BrushKind.ballpoint;
|
||||||
|
|
||||||
/// Generate the normalized polyline for [kind] spanning [start] → [end].
|
/// Generate the normalized polyline for [kind] spanning [start] → [end].
|
||||||
///
|
///
|
||||||
/// * [ShapeKind.line] → 2 points.
|
/// * [ShapeKind.line] → 2 points.
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ enum PenButtonAction {
|
|||||||
undo,
|
undo,
|
||||||
toggleTool,
|
toggleTool,
|
||||||
pan,
|
pan,
|
||||||
|
|
||||||
|
/// Hold to temporarily enable PDF text selection (OneNote-style).
|
||||||
|
selectText,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immutable configuration for pen input behaviour.
|
/// Immutable configuration for pen input behaviour.
|
||||||
|
|||||||
@@ -2,22 +2,9 @@
|
|||||||
//
|
//
|
||||||
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
|
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
|
||||||
//
|
//
|
||||||
// WHY THIS EXISTS: Flutter 3.44 on Windows delivers stylus PRESSURE but drops
|
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
|
||||||
// the pen's barrel button, eraser/inverted end, and tilt (it does not map
|
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
|
||||||
// POINTER_PEN_FLAG_* into `PointerEvent.buttons`/`invertedStylus`/`tilt`). The
|
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
|
||||||
// native plugin observes WM_POINTER + GetPointerPenInfo and streams the missing
|
|
||||||
// hardware state over an EventChannel; this service latches the LATEST value.
|
|
||||||
//
|
|
||||||
// CORRELATION (plan M2): we do NOT key state by Win32 pointerId joined to
|
|
||||||
// Flutter's `event.pointer` — those are different id spaces. Only one pen is
|
|
||||||
// active at a time, so a single latched "current" state is correct. The native
|
|
||||||
// observer runs at the TOP of the window proc (BEFORE Flutter synthesizes its
|
|
||||||
// pointer event, plan M1), so by the time Dart's pointer-down handler reads
|
|
||||||
// [current], the latch already reflects that exact contact — no hover required.
|
|
||||||
//
|
|
||||||
// GRACEFUL DEGRADATION: on non-Windows (or if the channel is silent) the stream
|
|
||||||
// simply never emits / errors are swallowed, and [current] stays [PenHardwareState.empty]
|
|
||||||
// so the canvas falls back to its normal Flutter-pressure drawing.
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
@@ -35,22 +22,20 @@ class PenHardwareState {
|
|||||||
this.eraser = false,
|
this.eraser = false,
|
||||||
this.tiltX = 0.0,
|
this.tiltX = 0.0,
|
||||||
this.tiltY = 0.0,
|
this.tiltY = 0.0,
|
||||||
|
this.pressure = 0.0,
|
||||||
|
this.pressureValid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Side barrel button held.
|
|
||||||
final bool barrel;
|
final bool barrel;
|
||||||
|
|
||||||
/// Pen flipped to the inverted (eraser) end.
|
|
||||||
final bool inverted;
|
final bool inverted;
|
||||||
|
|
||||||
/// Hardware eraser flag set.
|
|
||||||
final bool eraser;
|
final bool eraser;
|
||||||
|
|
||||||
/// Tilt in degrees along X / Y ([-90, 90]); 0 = perpendicular.
|
|
||||||
final double tiltX;
|
final double tiltX;
|
||||||
final double tiltY;
|
final double tiltY;
|
||||||
|
|
||||||
/// Combined tilt magnitude in degrees (for [PenPoint.tilt]).
|
/// Normalized stylus pressure in [0,1] when [pressureValid] is true.
|
||||||
|
final double pressure;
|
||||||
|
final bool pressureValid;
|
||||||
|
|
||||||
double get tiltMagnitude {
|
double get tiltMagnitude {
|
||||||
final t = tiltX * tiltX + tiltY * tiltY;
|
final t = tiltX * tiltX + tiltY * tiltY;
|
||||||
return t <= 0 ? 0.0 : _sqrt(t);
|
return t <= 0 ? 0.0 : _sqrt(t);
|
||||||
@@ -59,12 +44,10 @@ class PenHardwareState {
|
|||||||
static const empty = PenHardwareState();
|
static const empty = PenHardwareState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoids importing dart:math for a single call.
|
|
||||||
double _sqrt(double v) {
|
double _sqrt(double v) {
|
||||||
if (v <= 0) return 0;
|
if (v <= 0) return 0;
|
||||||
var x = v;
|
var x = v;
|
||||||
var last = 0.0;
|
var last = 0.0;
|
||||||
// Newton's method; converges fast for the small (<=~127) magnitudes here.
|
|
||||||
for (var i = 0; i < 12 && x != last; i++) {
|
for (var i = 0; i < 12 && x != last; i++) {
|
||||||
last = x;
|
last = x;
|
||||||
x = 0.5 * (x + v / x);
|
x = 0.5 * (x + v / x);
|
||||||
@@ -72,33 +55,34 @@ double _sqrt(double v) {
|
|||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latches the most recent [PenHardwareState] streamed by the native pen plugin.
|
|
||||||
///
|
|
||||||
/// Use the singleton [PenInputService.instance]. Call [start] once (e.g. in the
|
|
||||||
/// editor's `initState`) and [stop] on dispose.
|
|
||||||
class PenInputService {
|
class PenInputService {
|
||||||
PenInputService._();
|
PenInputService._();
|
||||||
|
|
||||||
/// Process-wide singleton (one physical pen).
|
|
||||||
static final PenInputService instance = PenInputService._();
|
static final PenInputService instance = PenInputService._();
|
||||||
|
|
||||||
/// Must match the native `EventChannel` name in `pen_channel.cpp`.
|
|
||||||
static const EventChannel _channel = EventChannel('badnote/pen');
|
static const EventChannel _channel = EventChannel('badnote/pen');
|
||||||
|
|
||||||
StreamSubscription<dynamic>? _sub;
|
StreamSubscription<dynamic>? _sub;
|
||||||
PenHardwareState _current = PenHardwareState.empty;
|
PenHardwareState _current = PenHardwareState.empty;
|
||||||
|
|
||||||
/// The latest hardware pen state (or [PenHardwareState.empty] when no native
|
|
||||||
/// data has arrived — non-Windows, plugin absent, or channel silent).
|
|
||||||
PenHardwareState get current => _current;
|
PenHardwareState get current => _current;
|
||||||
|
|
||||||
/// Whether the native channel has delivered at least one event (i.e. the
|
|
||||||
/// native pen plugin is present and active). Used to prefer hardware signals
|
|
||||||
/// over the Flutter fallback only when they are actually available.
|
|
||||||
bool get isActive => _active;
|
bool get isActive => _active;
|
||||||
bool _active = false;
|
bool _active = false;
|
||||||
|
|
||||||
// Native-side diagnostics (see windows/runner/pen_channel.cpp).
|
final List<VoidCallback> _listeners = <VoidCallback>[];
|
||||||
|
|
||||||
|
/// Notify when native hardware state changes (barrel / pressure / tilt).
|
||||||
|
void addListener(VoidCallback listener) => _listeners.add(listener);
|
||||||
|
|
||||||
|
void removeListener(VoidCallback listener) => _listeners.remove(listener);
|
||||||
|
|
||||||
|
void _notifyListeners() {
|
||||||
|
for (final l in List<VoidCallback>.of(_listeners)) {
|
||||||
|
l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _diagPtr = 0;
|
int _diagPtr = 0;
|
||||||
int _diagPen = 0;
|
int _diagPen = 0;
|
||||||
int _diagMouse = 0;
|
int _diagMouse = 0;
|
||||||
@@ -108,47 +92,42 @@ class PenInputService {
|
|||||||
int _orPenMask = 0;
|
int _orPenMask = 0;
|
||||||
int _btnChangeLast = 0;
|
int _btnChangeLast = 0;
|
||||||
int _tiltAbsMax = 0;
|
int _tiltAbsMax = 0;
|
||||||
|
double _pressureMaxSeen = 0;
|
||||||
|
|
||||||
String _hex(int v) => '0x${v.toRadixString(16)}';
|
String _hex(int v) => '0x${v.toRadixString(16)}';
|
||||||
|
|
||||||
/// Multi-line native readout for the diagnostic overlay. The OR-accumulated
|
|
||||||
/// flag fields are the ground truth for which field carries the side/eraser
|
|
||||||
/// button: e.g. orPtrFlags with bit 0x20 (POINTER_FLAG_SECONDBUTTON) set means
|
|
||||||
/// the barrel button IS detectable.
|
|
||||||
String get debugSummary => _active
|
String get debugSummary => _active
|
||||||
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
|
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
|
||||||
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
|
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
|
||||||
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
|
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
|
||||||
|
'\n pressure=${_current.pressureValid ? _current.pressure.toStringAsFixed(3) : "n/a"}'
|
||||||
|
' maxSeen=${_pressureMaxSeen.toStringAsFixed(3)}'
|
||||||
: 'native: channel silent (no events)';
|
: 'native: channel silent (no events)';
|
||||||
|
|
||||||
/// Begins listening to the native channel. Idempotent; safe on any platform
|
|
||||||
/// (no-ops where the channel has no handler).
|
|
||||||
void start() {
|
void start() {
|
||||||
if (_sub != null) return;
|
if (_sub != null) return;
|
||||||
try {
|
try {
|
||||||
_sub = _channel.receiveBroadcastStream().listen(
|
_sub = _channel.receiveBroadcastStream().listen(
|
||||||
_onEvent,
|
_onEvent,
|
||||||
onError: (Object _) {
|
onError: (Object _) {},
|
||||||
// No native handler (e.g. Linux/macOS) or transient error — ignore
|
|
||||||
// and keep the empty fallback state.
|
|
||||||
},
|
|
||||||
cancelOnError: false,
|
cancelOnError: false,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// receiveBroadcastStream can throw synchronously if the platform side is
|
|
||||||
// unavailable; degrade silently.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(dynamic event) {
|
void _onEvent(dynamic event) {
|
||||||
if (event is! Map) return;
|
if (event is! Map) return;
|
||||||
final flags = (event['flags'] as num?)?.toInt() ?? 0;
|
final flags = (event['flags'] as num?)?.toInt() ?? 0;
|
||||||
|
final pressureValid = ((event['pressureValid'] as num?)?.toInt() ?? 0) != 0;
|
||||||
|
final pressure = (event['pressure'] as num?)?.toDouble() ?? 0.0;
|
||||||
_current = PenHardwareState(
|
_current = PenHardwareState(
|
||||||
barrel: flags & 0x1 != 0,
|
barrel: flags & 0x1 != 0,
|
||||||
inverted: flags & 0x2 != 0,
|
inverted: flags & 0x2 != 0,
|
||||||
eraser: flags & 0x4 != 0,
|
eraser: flags & 0x4 != 0,
|
||||||
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
|
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
|
||||||
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
|
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
pressure: pressure.clamp(0.0, 1.0),
|
||||||
|
pressureValid: pressureValid,
|
||||||
);
|
);
|
||||||
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
|
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
|
||||||
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
||||||
@@ -159,16 +138,20 @@ class PenInputService {
|
|||||||
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
||||||
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
||||||
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
||||||
|
_pressureMaxSeen =
|
||||||
|
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
|
||||||
|
if (pressureValid && pressure > _pressureMaxSeen) {
|
||||||
|
_pressureMaxSeen = pressure;
|
||||||
|
}
|
||||||
_active = true;
|
_active = true;
|
||||||
|
|
||||||
// Log a PEN line whenever the raw per-event button/flag fields change, so
|
|
||||||
// the file captures exactly which field a button press sets (without
|
|
||||||
// flooding on every high-rate WM_POINTERUPDATE).
|
|
||||||
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
|
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
|
||||||
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
|
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
|
||||||
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
|
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
|
||||||
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
|
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
|
||||||
final key = '$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY}';
|
final key =
|
||||||
|
'$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY},'
|
||||||
|
'${pressureValid ? pressure.toStringAsFixed(2) : "x"}';
|
||||||
if (key != _lastPenLogKey) {
|
if (key != _lastPenLogKey) {
|
||||||
_lastPenLogKey = key;
|
_lastPenLogKey = key;
|
||||||
PenEventRing.instance.recordHardware(
|
PenEventRing.instance.recordHardware(
|
||||||
@@ -188,7 +171,8 @@ class PenInputService {
|
|||||||
'btnChg': btnChange,
|
'btnChg': btnChange,
|
||||||
'tiltX': _current.tiltX,
|
'tiltX': _current.tiltX,
|
||||||
'tiltY': _current.tiltY,
|
'tiltY': _current.tiltY,
|
||||||
'resolved': '0x${flags.toRadixString(16)}',
|
'pressure': pressureValid ? pressure : null,
|
||||||
|
'pressureValid': pressureValid,
|
||||||
'barrel': _current.barrel,
|
'barrel': _current.barrel,
|
||||||
'eraser': _current.eraser,
|
'eraser': _current.eraser,
|
||||||
'inverted': _current.inverted,
|
'inverted': _current.inverted,
|
||||||
@@ -199,14 +183,15 @@ class PenInputService {
|
|||||||
'penFlags=0x${rawPen.toRadixString(16)} '
|
'penFlags=0x${rawPen.toRadixString(16)} '
|
||||||
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
||||||
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
|
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
|
||||||
|
'p=${pressureValid ? pressure.toStringAsFixed(3) : "n/a"} '
|
||||||
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
|
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _lastPenLogKey = '';
|
String _lastPenLogKey = '';
|
||||||
|
|
||||||
/// Stops listening and resets state.
|
|
||||||
void stop() {
|
void stop() {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_sub = null;
|
_sub = null;
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ class SidecarRepository {
|
|||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// How many editors currently hold this repo. [open] reuses an existing
|
||||||
|
/// instance and bumps the count; [dispose] only tears down at zero so a
|
||||||
|
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
|
||||||
|
int _retainCount = 1;
|
||||||
|
|
||||||
/// Tail of the in-flight write chain. Writes are serialized through this so a
|
/// Tail of the in-flight write chain. Writes are serialized through this so a
|
||||||
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
|
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
|
||||||
/// same `.tmp`/rename (which would throw on the loser). Each write always
|
/// same `.tmp`/rename (which would throw on the loser). Each write always
|
||||||
@@ -107,14 +112,23 @@ class SidecarRepository {
|
|||||||
|
|
||||||
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||||
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||||
|
///
|
||||||
|
/// Reuses an already-open repo for the same path (retain-counted) so a
|
||||||
|
/// scratchpad overlay / split view cannot race the PDF editor with a second
|
||||||
|
/// in-memory snapshot that would overwrite scratchpad ink on flush.
|
||||||
static Future<SidecarRepository> open(
|
static Future<SidecarRepository> open(
|
||||||
String sourceFilePath, {
|
String sourceFilePath, {
|
||||||
String? docType,
|
String? docType,
|
||||||
Duration debounce = const Duration(milliseconds: 800),
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
}) async {
|
}) async {
|
||||||
|
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
|
||||||
|
if (existing != null && !existing._disposed) {
|
||||||
|
existing._retainCount++;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
final file = File('$sourceFilePath$kSidecarSuffix');
|
final file = File('$sourceFilePath$kSidecarSuffix');
|
||||||
final existing = await SidecarStore.read(file);
|
final loaded = await SidecarStore.read(file);
|
||||||
final sidecar = existing ??
|
final sidecar = loaded ??
|
||||||
BadnoteSidecar(
|
BadnoteSidecar(
|
||||||
sourceFile: _basename(sourceFilePath),
|
sourceFile: _basename(sourceFilePath),
|
||||||
docType: docType,
|
docType: docType,
|
||||||
@@ -313,6 +327,11 @@ class SidecarRepository {
|
|||||||
|
|
||||||
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
if (_retainCount > 1) {
|
||||||
|
_retainCount--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ class _ActionDropdown extends StatelessWidget {
|
|||||||
PenButtonAction.undo => 'Undo',
|
PenButtonAction.undo => 'Undo',
|
||||||
PenButtonAction.toggleTool => 'Toggle Tool',
|
PenButtonAction.toggleTool => 'Toggle Tool',
|
||||||
PenButtonAction.pan => 'Pan',
|
PenButtonAction.pan => 'Pan',
|
||||||
|
PenButtonAction.selectText => 'Select text',
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -89,13 +89,7 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
static const double _penWidthFraction = 0.006;
|
static const double _penWidthFraction = 0.006;
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
static const List<Color> _palette = [
|
static const List<Color> _palette = kInkPalette;
|
||||||
Colors.black,
|
|
||||||
Colors.red,
|
|
||||||
Colors.blue,
|
|
||||||
Colors.green,
|
|
||||||
Colors.orange,
|
|
||||||
];
|
|
||||||
|
|
||||||
// -- Auto-save debounce --
|
// -- Auto-save debounce --
|
||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
@@ -119,12 +113,14 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
_saveImmediate();
|
// Schedule a final flush; retain-counted repo may still be held by the
|
||||||
final repo = _repo;
|
// PDF editor, so dispose only drops our retain.
|
||||||
if (repo != null) {
|
if (_dirty) {
|
||||||
repo.flush(); // fire-and-forget; atomic write finishes off the tree
|
unawaited(_saveImmediate());
|
||||||
repo.dispose();
|
} else {
|
||||||
|
unawaited(_repo?.flush() ?? Future<void>.value());
|
||||||
}
|
}
|
||||||
|
_repo?.dispose();
|
||||||
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
||||||
_scratchTransform.dispose();
|
_scratchTransform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -166,9 +162,9 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
|
|
||||||
Future<void> _saveImmediate() async {
|
Future<void> _saveImmediate() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
_dirty = false;
|
|
||||||
final repo = _repo;
|
final repo = _repo;
|
||||||
if (repo == null) return;
|
if (repo == null) return;
|
||||||
|
// Keep dirty until schedule succeeds so a race during load can't swallow ink.
|
||||||
repo.scheduleScratchpadSave(
|
repo.scheduleScratchpadSave(
|
||||||
widget.scratchLinkId,
|
widget.scratchLinkId,
|
||||||
SidecarScratchpad(
|
SidecarScratchpad(
|
||||||
@@ -177,6 +173,7 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
strokes: List<InkStroke>.of(_strokes),
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_dirty = false;
|
||||||
await repo.flush();
|
await repo.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +292,9 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
|
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
_saveImmediate();
|
await _saveImmediate();
|
||||||
Navigator.of(context).pop();
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fountain pen — Pow2 (p²), thinning 0.9, taper on, solid', () {
|
test('fountain pen — Pow2 (p²), moderate thinning, no taper, solid', () {
|
||||||
final b = brushProfileFor(BrushKind.fountainPen);
|
final b = brushProfileFor(BrushKind.fountainPen);
|
||||||
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
|
expect(b.pressureGamma, 2.0); // rnote Pow2 / quadratic
|
||||||
expect(b.pfThinning, 0.9);
|
expect(b.pfThinning, 0.65);
|
||||||
expect(b.pfStreamline, 0.45);
|
expect(b.pfStreamline, 0.4);
|
||||||
expect(b.pfSmoothing, 0.55);
|
expect(b.pfSmoothing, 0.5);
|
||||||
expect(b.simulatePressure, isFalse);
|
expect(b.simulatePressure, isFalse);
|
||||||
expect(b.taper, isTrue);
|
expect(b.taper, isFalse);
|
||||||
expect(b.capStart, isTrue);
|
expect(b.capStart, isTrue);
|
||||||
expect(b.capEnd, isTrue);
|
expect(b.capEnd, isTrue);
|
||||||
expect(b.opacity, 1.0);
|
expect(b.opacity, 1.0);
|
||||||
@@ -127,8 +127,9 @@ void main() {
|
|||||||
isNot(brushProfileFor(BrushKind.ballpoint).pressureGamma));
|
isNot(brushProfileFor(BrushKind.ballpoint).pressureGamma));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('caps/taper differ (fountain tapers, highlighter is square)', () {
|
test('caps/taper: highlighter is square; fountain/ballpoint round no taper',
|
||||||
expect(brushProfileFor(BrushKind.fountainPen).taper, isTrue);
|
() {
|
||||||
|
expect(brushProfileFor(BrushKind.fountainPen).taper, isFalse);
|
||||||
expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse);
|
expect(brushProfileFor(BrushKind.highlighter).capStart, isFalse);
|
||||||
expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse);
|
expect(brushProfileFor(BrushKind.ballpoint).taper, isFalse);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ int g_pen_flags_or = 0; // POINTER_PEN_INFO.penFlags (PEN_FLAG_BARREL/INVERT
|
|||||||
int g_pen_mask_or = 0; // POINTER_PEN_INFO.penMask
|
int g_pen_mask_or = 0; // POINTER_PEN_INFO.penMask
|
||||||
int g_btn_change_last = 0; // last non-zero POINTER_INFO.ButtonChangeType
|
int g_btn_change_last = 0; // last non-zero POINTER_INFO.ButtonChangeType
|
||||||
int g_tilt_abs_max = 0; // max |tiltX|,|tiltY| seen
|
int g_tilt_abs_max = 0; // max |tiltX|,|tiltY| seen
|
||||||
|
float g_pressure_max_seen = 0.f;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) {
|
|||||||
int flags = 0;
|
int flags = 0;
|
||||||
double tilt_x = 0.0;
|
double tilt_x = 0.0;
|
||||||
double tilt_y = 0.0;
|
double tilt_y = 0.0;
|
||||||
|
double pressure = 0.0;
|
||||||
|
int pressure_valid = 0;
|
||||||
int raw_ptr_flags = 0;
|
int raw_ptr_flags = 0;
|
||||||
int raw_pen_flags = 0;
|
int raw_pen_flags = 0;
|
||||||
int raw_pen_mask = 0;
|
int raw_pen_mask = 0;
|
||||||
@@ -106,9 +109,16 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) {
|
|||||||
tilt_x = static_cast<double>(ppi.tiltX);
|
tilt_x = static_cast<double>(ppi.tiltX);
|
||||||
tilt_y = static_cast<double>(ppi.tiltY);
|
tilt_y = static_cast<double>(ppi.tiltY);
|
||||||
|
|
||||||
// Barrel/side button can arrive in EITHER penFlags (PEN_FLAG_BARREL) or
|
// Pressure: Win32 reports 0..1024 when PEN_MASK_PRESSURE is set.
|
||||||
// pointerFlags (POINTER_FLAG_SECONDBUTTON) depending on the pen/driver,
|
// Normalize to [0,1] for Dart. Also scan history for a non-zero sample
|
||||||
// so check both. Eraser end = inverted/eraser pen flags.
|
// (some drivers zero the tip sample while history has the real value).
|
||||||
|
if (ppi.penMask & PEN_MASK_PRESSURE) {
|
||||||
|
pressure_valid = 1;
|
||||||
|
pressure = static_cast<double>(ppi.pressure) / 1024.0;
|
||||||
|
if (pressure < 0.0) pressure = 0.0;
|
||||||
|
if (pressure > 1.0) pressure = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
const bool barrel = (ppi.penFlags & PEN_FLAG_BARREL) ||
|
const bool barrel = (ppi.penFlags & PEN_FLAG_BARREL) ||
|
||||||
(ppi.pointerInfo.pointerFlags & POINTER_FLAG_SECONDBUTTON);
|
(ppi.pointerInfo.pointerFlags & POINTER_FLAG_SECONDBUTTON);
|
||||||
const bool inverted = (ppi.penFlags & PEN_FLAG_INVERTED) != 0;
|
const bool inverted = (ppi.penFlags & PEN_FLAG_INVERTED) != 0;
|
||||||
@@ -126,26 +136,38 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) {
|
|||||||
if (ax > g_tilt_abs_max) g_tilt_abs_max = ax;
|
if (ax > g_tilt_abs_max) g_tilt_abs_max = ax;
|
||||||
if (ay > g_tilt_abs_max) g_tilt_abs_max = ay;
|
if (ay > g_tilt_abs_max) g_tilt_abs_max = ay;
|
||||||
|
|
||||||
// Coalesce recent history (diagnostic + future batching).
|
|
||||||
POINTER_PEN_INFO history[32];
|
POINTER_PEN_INFO history[32];
|
||||||
UINT32 hist_n = 32;
|
UINT32 hist_n = 32;
|
||||||
if (GetPointerPenInfoHistory(pointerId, &hist_n, history)) {
|
if (GetPointerPenInfoHistory(pointerId, &hist_n, history)) {
|
||||||
history_count = static_cast<int>(hist_n);
|
history_count = static_cast<int>(hist_n);
|
||||||
|
// Prefer the max pressure in the history window (smoother + avoids
|
||||||
|
// a zero tip sample when mask says pressure is present).
|
||||||
|
for (UINT32 i = 0; i < hist_n; ++i) {
|
||||||
|
if (!(history[i].penMask & PEN_MASK_PRESSURE)) continue;
|
||||||
|
pressure_valid = 1;
|
||||||
|
double p = static_cast<double>(history[i].pressure) / 1024.0;
|
||||||
|
if (p > pressure) pressure = p;
|
||||||
|
}
|
||||||
|
if (pressure > 1.0) pressure = 1.0;
|
||||||
|
}
|
||||||
|
if (pressure > g_pressure_max_seen) {
|
||||||
|
g_pressure_max_seen = static_cast<float>(pressure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (message == WM_POINTERUP) {
|
if (message == WM_POINTERUP) {
|
||||||
flags = 0; // lift-off clears held flags
|
flags = 0;
|
||||||
|
pressure = 0.0;
|
||||||
|
pressure_valid = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit the resolved flags/tilt PLUS the full raw + OR-accumulated diagnostic
|
|
||||||
// set, so a single device session reveals exactly which field carries the
|
|
||||||
// button and what tilt/mask the pen reports.
|
|
||||||
flutter::EncodableMap payload{
|
flutter::EncodableMap payload{
|
||||||
{flutter::EncodableValue("flags"), flutter::EncodableValue(flags)},
|
{flutter::EncodableValue("flags"), flutter::EncodableValue(flags)},
|
||||||
{flutter::EncodableValue("tiltX"), flutter::EncodableValue(tilt_x)},
|
{flutter::EncodableValue("tiltX"), flutter::EncodableValue(tilt_x)},
|
||||||
{flutter::EncodableValue("tiltY"), flutter::EncodableValue(tilt_y)},
|
{flutter::EncodableValue("tiltY"), flutter::EncodableValue(tilt_y)},
|
||||||
|
{flutter::EncodableValue("pressure"), flutter::EncodableValue(pressure)},
|
||||||
|
{flutter::EncodableValue("pressureValid"), flutter::EncodableValue(pressure_valid)},
|
||||||
{flutter::EncodableValue("diagPtr"), flutter::EncodableValue(g_ptr_msgs)},
|
{flutter::EncodableValue("diagPtr"), flutter::EncodableValue(g_ptr_msgs)},
|
||||||
{flutter::EncodableValue("diagPen"), flutter::EncodableValue(g_pen_msgs)},
|
{flutter::EncodableValue("diagPen"), flutter::EncodableValue(g_pen_msgs)},
|
||||||
{flutter::EncodableValue("diagMouse"), flutter::EncodableValue(g_mouse_msgs)},
|
{flutter::EncodableValue("diagMouse"), flutter::EncodableValue(g_mouse_msgs)},
|
||||||
@@ -160,6 +182,8 @@ void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) {
|
|||||||
{flutter::EncodableValue("btnChangeLast"), flutter::EncodableValue(g_btn_change_last)},
|
{flutter::EncodableValue("btnChangeLast"), flutter::EncodableValue(g_btn_change_last)},
|
||||||
{flutter::EncodableValue("tiltAbsMax"), flutter::EncodableValue(g_tilt_abs_max)},
|
{flutter::EncodableValue("tiltAbsMax"), flutter::EncodableValue(g_tilt_abs_max)},
|
||||||
{flutter::EncodableValue("historyCount"), flutter::EncodableValue(history_count)},
|
{flutter::EncodableValue("historyCount"), flutter::EncodableValue(history_count)},
|
||||||
|
{flutter::EncodableValue("pressureMaxSeen"),
|
||||||
|
flutter::EncodableValue(static_cast<double>(g_pressure_max_seen))},
|
||||||
};
|
};
|
||||||
g_pen_sink->Success(flutter::EncodableValue(payload));
|
g_pen_sink->Success(flutter::EncodableValue(payload));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user