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]).
|
||||
///
|
||||
/// 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) {
|
||||
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;
|
||||
if (range > 0.0001) {
|
||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||
@@ -560,7 +568,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: PenStrokeKind.pen,
|
||||
brush: _currentBrush,
|
||||
brush: kShapeBrush,
|
||||
));
|
||||
}
|
||||
} else if (tool == CanvasTool.select) {
|
||||
@@ -619,7 +627,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: PenStrokeKind.pen,
|
||||
brush: _currentBrush,
|
||||
brush: kShapeBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import 'package:uuid/uuid.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bookmark.dart';
|
||||
import '../../models/scratch_link.dart';
|
||||
import '../../screens/split_view_screen.dart';
|
||||
import '../../storage/badnote_sidecar.dart';
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
@@ -56,6 +55,7 @@ import 'input_diagnostics.dart';
|
||||
import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.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
|
||||
/// (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
|
||||
/// 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;
|
||||
|
||||
/// 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
|
||||
/// pointer-count re-baseline). Null when no pinch is active.
|
||||
@@ -247,9 +248,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
? BrushKind.highlighter
|
||||
: _penBrush;
|
||||
|
||||
/// When true the "select text" tool is active: pen capture is disabled so the
|
||||
/// pen falls through to pdfrx for native text selection.
|
||||
bool _selectTextMode = false;
|
||||
/// When true the "select text" tool button is latched on.
|
||||
bool _selectTextTool = 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
|
||||
/// 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 _highlighterWidthFraction = 0.02;
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
static const List<Color> _palette = kInkPalette;
|
||||
|
||||
/// 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
|
||||
/// 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 =>
|
||||
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode && !_textMode;
|
||||
!_selectTextMode &&
|
||||
!_placeLinkMode &&
|
||||
!_removeHighlightMode &&
|
||||
!_textMode &&
|
||||
_expandedSticky == null;
|
||||
|
||||
/// True when the eraser tool is active.
|
||||
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).
|
||||
// No-op on platforms without the plugin (W3).
|
||||
PenInputService.instance.start();
|
||||
PenInputService.instance.addListener(_onHwPenChanged);
|
||||
_initPersistence();
|
||||
_initPenConfig();
|
||||
}
|
||||
@@ -339,6 +348,34 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
void _onPenConfigChanged() {
|
||||
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 {
|
||||
@@ -412,7 +449,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
_overlayRepaint.dispose();
|
||||
_liveStrokeVN.dispose();
|
||||
_penConfig?.removeListener(_onPenConfigChanged);
|
||||
_penConfig?.dispose();
|
||||
PenInputService.instance.removeListener(_onHwPenChanged);
|
||||
PenInputService.instance.stop();
|
||||
DiagnosticLogger.instance.stop();
|
||||
super.dispose();
|
||||
@@ -509,6 +548,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
|
||||
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;
|
||||
if (range > 0.0001) {
|
||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||
@@ -538,6 +581,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
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) {
|
||||
if (_isStylus(event.kind)) _emitPenDebug(event);
|
||||
|
||||
@@ -545,6 +601,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
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) {
|
||||
_liveStrokePage = hit.page;
|
||||
_eraseAt(hit.page, hit.normalized);
|
||||
@@ -687,7 +749,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: _currentStrokeWidth(),
|
||||
kind: PenStrokeKind.pen,
|
||||
brush: _currentBrush(),
|
||||
brush: kShapeBrush,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -847,7 +909,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
final scaleDrop =
|
||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
||||
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(
|
||||
scaleStart: _pinchScaleStart!,
|
||||
@@ -866,6 +939,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
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;
|
||||
_pinchLastAppliedScale = targetScale;
|
||||
}
|
||||
@@ -1071,7 +1156,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
void _setTool(EditorToolKind tool) {
|
||||
setState(() {
|
||||
_tool = tool;
|
||||
_selectTextMode = false;
|
||||
_selectTextTool = false;
|
||||
_placeLinkMode = false;
|
||||
_removeHighlightMode = false;
|
||||
// The TEXT tool is the one EditorToolKind that drives a page-anchored
|
||||
@@ -1083,11 +1168,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
void _enableSelectText() {
|
||||
setState(() {
|
||||
_selectTextMode = true;
|
||||
_selectTextTool = true;
|
||||
_placeLinkMode = false;
|
||||
_removeHighlightMode = false;
|
||||
_textMode = false;
|
||||
_selected = null;
|
||||
_expandedSticky = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1097,9 +1183,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() {
|
||||
_placeLinkMode = !_placeLinkMode;
|
||||
if (_placeLinkMode) {
|
||||
_selectTextMode = false;
|
||||
_selectTextTool = false;
|
||||
_removeHighlightMode = false;
|
||||
_textMode = false;
|
||||
_expandedSticky = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1110,10 +1197,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() {
|
||||
_removeHighlightMode = !_removeHighlightMode;
|
||||
if (_removeHighlightMode) {
|
||||
_selectTextMode = false;
|
||||
_selectTextTool = false;
|
||||
_placeLinkMode = false;
|
||||
_textMode = false;
|
||||
_selected = null;
|
||||
_expandedSticky = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1138,35 +1226,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() => _scratchLinks.add(link));
|
||||
}
|
||||
|
||||
/// Open the anchor's split view (left = this PDF at the anchor page, right =
|
||||
/// the anchor's private infinite scratchpad, stored in the sidecar).
|
||||
///
|
||||
/// 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.
|
||||
/// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME
|
||||
/// [SidecarRepository] — no second open, no split-view race.
|
||||
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;
|
||||
await Navigator.of(context).push(
|
||||
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;
|
||||
setState(() => _expandedSticky = null);
|
||||
}
|
||||
|
||||
/// Confirm + delete an anchor (and its private scratchpad).
|
||||
@@ -1694,6 +1768,32 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
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.
|
||||
/// 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 {
|
||||
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
|
||||
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
|
||||
/// automatically (no special plugin; a focusable text input is all the panel
|
||||
/// needs). Autofocuses on insert; commits via [onChanged] (debounced persist)
|
||||
/// and finishes via [onDone] (submit / focus loss).
|
||||
/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop
|
||||
/// the soft keyboard on every pen tap that places a box); focus only after an
|
||||
/// explicit tap on the field.
|
||||
class _TextAnnotationField extends StatefulWidget {
|
||||
const _TextAnnotationField({
|
||||
super.key,
|
||||
@@ -2370,11 +2512,6 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
||||
_controller = TextEditingController(text: widget.initialText);
|
||||
_focusNode = FocusNode();
|
||||
_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() {
|
||||
@@ -2399,7 +2536,7 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
autofocus: false,
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
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
|
||||
/// 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.
|
||||
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;
|
||||
|
||||
/// 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,
|
||||
/// and the frame is dropped (position-jump guard).
|
||||
const double _kFocalGlitchPx = 250.0;
|
||||
const double _kFocalGlitchPx = 100.0;
|
||||
|
||||
const double _kDrag = 0.0000135;
|
||||
|
||||
|
||||
@@ -111,13 +111,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
static const double _penWidthFraction = 0.006;
|
||||
static const double _highlighterWidthFraction = 0.02;
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
static const List<Color> _palette = kInkPalette;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
||||
@@ -10,6 +10,22 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../engine/brush.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).
|
||||
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
|
||||
BrushKind.fountainPen => l.brushFountainPen,
|
||||
|
||||
@@ -93,13 +93,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
static const double _highlighterWidthFraction = 0.02;
|
||||
static const Size _fallbackSlide = Size(1600, 900);
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
static const List<Color> _palette = kInkPalette;
|
||||
|
||||
int get _slideCount => widget.slideImagePaths.length;
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user