fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/pen_physics.dart';
|
||||
import '../engine/stroke_eraser.dart';
|
||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
import '../engine/stroke_model.dart';
|
||||
@@ -33,6 +34,7 @@ import '../input/pressure_curve.dart';
|
||||
import '../input/pen_input_service.dart';
|
||||
import '../../diagnostics/pen_event_ring.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../render/ink_picture_cache.dart';
|
||||
import '../render/live_ink_painter.dart' as render;
|
||||
import '../render/static_ink_painter.dart' as render;
|
||||
@@ -230,6 +232,10 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
|
||||
bool _selectDragging = false;
|
||||
|
||||
/// Tip-velocity tracker for [tipVelocityWidthScale] (physical ink starvation).
|
||||
Offset? _lastTipNorm;
|
||||
Duration? _lastTipTime;
|
||||
|
||||
/// True when the active stylus reports the eraser signal (barrel button or
|
||||
/// inverted stylus), detected on hover/down.
|
||||
bool _eraserActive = false;
|
||||
@@ -399,7 +405,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
if (action == _lastHwAction) return;
|
||||
_lastHwAction = action;
|
||||
if (action == PenButtonAction.undo ||
|
||||
action == PenButtonAction.toggleTool) {
|
||||
action == PenButtonAction.toggleTool ||
|
||||
action == PenButtonAction.select) {
|
||||
widget.onPenButtonAction?.call(action);
|
||||
}
|
||||
}
|
||||
@@ -441,7 +448,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// Map a global pointer position into normalized page coords using the
|
||||
/// shared transform (inverse) and this widget's geometry.
|
||||
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
||||
{double? tilt}) {
|
||||
{double? tilt, Duration? timeStamp}) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null) return null;
|
||||
final local = box.globalToLocal(globalPosition);
|
||||
@@ -451,7 +458,23 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
final nx = scene.dx / widget.pageSize.width;
|
||||
final ny = scene.dy / widget.pageSize.height;
|
||||
return PenPoint(nx, ny, pressure, tilt: tilt);
|
||||
|
||||
double? shaped = pressure;
|
||||
if (shaped != null && timeStamp != null && _lastTipNorm != null &&
|
||||
_lastTipTime != null) {
|
||||
final dt = (timeStamp - _lastTipTime!).inMicroseconds / 1e6;
|
||||
if (dt > 0) {
|
||||
final dx = nx - _lastTipNorm!.dx;
|
||||
final dy = ny - _lastTipNorm!.dy;
|
||||
final speed = math.sqrt(dx * dx + dy * dy) / dt;
|
||||
shaped = (shaped * tipVelocityWidthScale(_currentBrush, speed))
|
||||
.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
_lastTipNorm = Offset(nx, ny);
|
||||
_lastTipTime = timeStamp;
|
||||
|
||||
return PenPoint(nx, ny, shaped, tilt: tilt);
|
||||
}
|
||||
|
||||
// --- Stroke lifecycle -----------------------------------------------------
|
||||
@@ -464,8 +487,10 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_lastTipNorm = null;
|
||||
_lastTipTime = null;
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event));
|
||||
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
_eraserCursor.value = p;
|
||||
@@ -502,7 +527,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
void _extendStroke(PointerMoveEvent event) {
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event));
|
||||
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||
if (p == null) return;
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show ValueListenable, visibleForTesting;
|
||||
@@ -37,6 +38,7 @@ import '../../models/scratch_link.dart';
|
||||
import '../../storage/badnote_sidecar.dart';
|
||||
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/pen_physics.dart';
|
||||
import '../engine/shape_geometry.dart';
|
||||
import '../engine/stroke_eraser.dart';
|
||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
@@ -113,6 +115,26 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// dragged (like the slide editor's scrubber); null when not scrubbing.
|
||||
double? _pageScrub;
|
||||
|
||||
/// Scrubber is opt-in (tap page label); not always on-screen.
|
||||
bool _showPageScrubber = false;
|
||||
|
||||
/// Bottom page chrome auto-hides after idle (OneNote-like).
|
||||
bool _pageChromeVisible = true;
|
||||
Timer? _pageChromeHideTimer;
|
||||
|
||||
/// Finger-ink pointer tracking (PDF path; does NOT go through PenCaptureRegion).
|
||||
final Set<int> _fingerPointers = <int>{};
|
||||
bool _fingerStrokeActive = false;
|
||||
|
||||
Offset? _lastTipNorm;
|
||||
Duration? _lastTipTime;
|
||||
|
||||
/// Per-page sticky that was open when the user left the page — restored on return.
|
||||
final Map<int, String> _stickyRememberedByPage = <int, String>{};
|
||||
|
||||
/// Rising-edge tracker for hardware side-button actions on PDF.
|
||||
PenButtonAction _lastHwSideAction = PenButtonAction.none;
|
||||
|
||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||
|
||||
@@ -370,6 +392,28 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
void _onHwPenChanged() {
|
||||
_syncBarrelSelectText();
|
||||
_dispatchHwSideButton();
|
||||
}
|
||||
|
||||
/// Rising-edge side-button → universal stroke select (default mapping).
|
||||
void _dispatchHwSideButton() {
|
||||
final cfg = _penConfig?.value;
|
||||
final hw = PenInputService.instance;
|
||||
if (cfg == null || !hw.isActive) return;
|
||||
final action = hw.current.barrel ? cfg.sideButton : PenButtonAction.none;
|
||||
if (action == _lastHwSideAction) return;
|
||||
final prev = _lastHwSideAction;
|
||||
_lastHwSideAction = action;
|
||||
if (action == PenButtonAction.select && prev != PenButtonAction.select) {
|
||||
_setTool(EditorToolKind.select);
|
||||
} else if (action == PenButtonAction.undo && prev != PenButtonAction.undo) {
|
||||
if (_undoFor(_pageIndex).canUndo) _performUndo();
|
||||
} else if (action == PenButtonAction.toggleTool &&
|
||||
prev != PenButtonAction.toggleTool) {
|
||||
_setTool(_tool == EditorToolKind.eraser
|
||||
? EditorToolKind.brush
|
||||
: EditorToolKind.eraser);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level-trigger: holding barrel with sideButton=selectText enables text
|
||||
@@ -458,6 +502,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageChromeHideTimer?.cancel();
|
||||
// Flush any pending sidecar write before tearing down.
|
||||
final repo = _repo;
|
||||
if (repo != null) {
|
||||
@@ -630,6 +675,71 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Brush gamma pressure, then tip-velocity physical scale (fountain thins at speed).
|
||||
double? _pressureWithPhysics(PointerEvent event, Offset normalized) {
|
||||
final base = _normalizedPressure(event);
|
||||
if (base == null) {
|
||||
_lastTipNorm = normalized;
|
||||
_lastTipTime = event.timeStamp;
|
||||
return null;
|
||||
}
|
||||
double shaped = base;
|
||||
if (_lastTipNorm != null && _lastTipTime != null) {
|
||||
final dt = (event.timeStamp - _lastTipTime!).inMicroseconds / 1e6;
|
||||
if (dt > 0) {
|
||||
final dx = normalized.dx - _lastTipNorm!.dx;
|
||||
final dy = normalized.dy - _lastTipNorm!.dy;
|
||||
final speed = math.sqrt(dx * dx + dy * dy) / dt;
|
||||
shaped = (shaped * tipVelocityWidthScale(_currentBrush(), speed))
|
||||
.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
_lastTipNorm = normalized;
|
||||
_lastTipTime = event.timeStamp;
|
||||
return shaped;
|
||||
}
|
||||
|
||||
void _onFingerPointer(PointerEvent event) {
|
||||
if (!_allowFingerDrawing || !_penCaptureEnabled) return;
|
||||
if (event.kind != PointerDeviceKind.touch) return;
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
_fingerPointers.add(event.pointer);
|
||||
if (_fingerPointers.length >= 2) {
|
||||
if (_fingerStrokeActive) {
|
||||
_endStroke(commit: false);
|
||||
_fingerStrokeActive = false;
|
||||
setState(() {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!_fingerStrokeActive) {
|
||||
setState(() => _fingerStrokeActive = true);
|
||||
}
|
||||
_onPenEvent(event);
|
||||
return;
|
||||
}
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!_fingerStrokeActive || !_fingerPointers.contains(event.pointer)) {
|
||||
return;
|
||||
}
|
||||
if (_fingerPointers.length >= 2) return;
|
||||
_onPenEvent(event);
|
||||
return;
|
||||
}
|
||||
if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_fingerPointers.remove(event.pointer);
|
||||
if (_fingerStrokeActive && _fingerPointers.isEmpty) {
|
||||
_onPenEvent(event);
|
||||
_fingerStrokeActive = false;
|
||||
if (mounted) setState(() {});
|
||||
} else if (_fingerPointers.isEmpty && _fingerStrokeActive) {
|
||||
_fingerStrokeActive = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onPenEvent(PointerEvent event) {
|
||||
if (_isStylus(event.kind)) _emitPenDebug(event);
|
||||
|
||||
@@ -661,10 +771,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
return;
|
||||
}
|
||||
_liveStrokePage = hit.page;
|
||||
_lastTipNorm = null;
|
||||
_lastTipTime = null;
|
||||
_livePoints
|
||||
..clear()
|
||||
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
|
||||
_normalizedPressure(event)));
|
||||
_pressureWithPhysics(event, hit.normalized)));
|
||||
_updateLiveStroke();
|
||||
} else if (event is PointerMoveEvent) {
|
||||
final page = _liveStrokePage;
|
||||
@@ -689,7 +801,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
// A stroke belongs to ONE page: ignore samples on a different page.
|
||||
if (hit == null || hit.page != page) return;
|
||||
_livePoints.add(PenPoint(
|
||||
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
|
||||
hit.normalized.dx,
|
||||
hit.normalized.dy,
|
||||
_pressureWithPhysics(event, hit.normalized)));
|
||||
_updateLiveStroke();
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_endStroke(commit: event is PointerUpEvent);
|
||||
@@ -1273,9 +1387,57 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
void _goToPage(int index) {
|
||||
if (_pageCount == 0) return;
|
||||
final clamped = index.clamp(0, _pageCount - 1);
|
||||
// Optimistic index so the pill doesn't flash the old page while pdfrx animates.
|
||||
if (clamped != _pageIndex) {
|
||||
setState(() {
|
||||
_pageIndex = clamped;
|
||||
_pageScrub = null;
|
||||
});
|
||||
_onPageIndexChanging(clamped);
|
||||
} else {
|
||||
setState(() => _pageScrub = null);
|
||||
}
|
||||
_bumpPageChrome();
|
||||
_controller.goToPage(pageNumber: clamped + 1);
|
||||
}
|
||||
|
||||
void _bumpPageChrome() {
|
||||
_pageChromeHideTimer?.cancel();
|
||||
if (!_pageChromeVisible && mounted) {
|
||||
setState(() => _pageChromeVisible = true);
|
||||
}
|
||||
_pageChromeHideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (!mounted) return;
|
||||
if (_pageScrub != null || _showPageScrubber) return;
|
||||
setState(() {
|
||||
_pageChromeVisible = false;
|
||||
_showPageScrubber = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide sticky when leaving its page; remember id so returning re-expands it.
|
||||
void _onPageIndexChanging(int newIndex) {
|
||||
final open = _expandedSticky;
|
||||
if (open != null && open.pageIndex != newIndex) {
|
||||
_stickyRememberedByPage[open.pageIndex] = open.id;
|
||||
_expandedSticky = null;
|
||||
}
|
||||
final rememberedId = _stickyRememberedByPage[newIndex];
|
||||
if (rememberedId != null && _expandedSticky == null) {
|
||||
ScratchLink? link;
|
||||
for (final s in _scratchLinks) {
|
||||
if (s.id == rememberedId) {
|
||||
link = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (link != null) {
|
||||
_expandedSticky = link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _setTool(EditorToolKind tool) {
|
||||
setState(() {
|
||||
_tool = tool;
|
||||
@@ -1363,7 +1525,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
|
||||
void _closeSticky() {
|
||||
if (!mounted) return;
|
||||
setState(() => _expandedSticky = null);
|
||||
final open = _expandedSticky;
|
||||
setState(() {
|
||||
if (open != null) {
|
||||
// Explicit close: do not auto-reopen when returning to this page.
|
||||
_stickyRememberedByPage.remove(open.pageIndex);
|
||||
}
|
||||
_expandedSticky = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Confirm + delete an anchor (and its private scratchpad).
|
||||
@@ -1687,16 +1856,50 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Floating page-control pill (bottom-center).
|
||||
if (_viewerReady && _pageCount > 0)
|
||||
// Floating page-control pill (bottom-center). Auto-hides when idle.
|
||||
if (_viewerReady && _pageCount > 0 && _pageChromeVisible)
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: _hasSelection ? 72 : 16,
|
||||
),
|
||||
child: _buildPagePill(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_viewerReady && _pageCount > 0)
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Material(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh
|
||||
.withValues(alpha: 0.92),
|
||||
elevation: 2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: _bumpPageChrome,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 6),
|
||||
child: Text(
|
||||
'${_pageIndex + 1} / $_pageCount',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Back button (top-left).
|
||||
SafeArea(
|
||||
@@ -1728,7 +1931,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
|
||||
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
|
||||
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
|
||||
panEnabled: true,
|
||||
// Suppress 1-finger pan while a finger-ink stroke is active so the
|
||||
// page doesn't scroll under the stroke (finger draw is opt-in).
|
||||
panEnabled: !_fingerStrokeActive,
|
||||
scaleEnabled: false,
|
||||
// Native vector text selection. Pen falls through to this only in
|
||||
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
||||
@@ -1742,6 +1947,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_viewerReady = true;
|
||||
_pageCount = document.pages.length;
|
||||
});
|
||||
_bumpPageChrome();
|
||||
// Honor a requested initial page (search-result jump), clamped.
|
||||
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
||||
if (target > 0) {
|
||||
@@ -1751,7 +1957,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
onPageChanged: (pageNumber) {
|
||||
if (pageNumber == null || !mounted) return;
|
||||
final idx = pageNumber - 1;
|
||||
if (idx != _pageIndex) setState(() => _pageIndex = idx);
|
||||
if (idx != _pageIndex) {
|
||||
setState(() {
|
||||
_onPageIndexChanging(idx);
|
||||
_pageIndex = idx;
|
||||
if (_pageScrub != null &&
|
||||
(_pageScrub!.round() - 1) == idx) {
|
||||
_pageScrub = null;
|
||||
}
|
||||
});
|
||||
_bumpPageChrome();
|
||||
}
|
||||
},
|
||||
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
|
||||
// normalized page space scaled to the on-screen page rect.
|
||||
@@ -1921,6 +2137,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Listener(
|
||||
behavior: _fingerStrokeActive
|
||||
? HitTestBehavior.opaque
|
||||
: HitTestBehavior.translucent,
|
||||
onPointerDown: _onFingerPointer,
|
||||
onPointerMove: _onFingerPointer,
|
||||
onPointerUp: _onFingerPointer,
|
||||
onPointerCancel: _onFingerPointer,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: PenCaptureRegion(
|
||||
captureEnabled: _penCaptureEnabled,
|
||||
@@ -1932,7 +2160,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
bottom: 88,
|
||||
child: _SelectionActionBar(
|
||||
onHighlight: _highlightSelection,
|
||||
onBookmark: _addBookmark,
|
||||
@@ -1946,6 +2174,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
key: ValueKey(_expandedSticky!.id),
|
||||
link: _expandedSticky!,
|
||||
repo: _repo!,
|
||||
brush: _penBrush,
|
||||
color: _color,
|
||||
tool: _tool,
|
||||
allowFingerDrawing: _allowFingerDrawing,
|
||||
onClose: _closeSticky,
|
||||
onDelete: () async {
|
||||
final link = _expandedSticky!;
|
||||
@@ -1973,17 +2205,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
|
||||
tooltip: l.brushPicker,
|
||||
labelFor: (b) => brushLabel(b, l),
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) {
|
||||
setState(() => _penBrush = b);
|
||||
_setTool(EditorToolKind.brush);
|
||||
},
|
||||
),
|
||||
// OneNote-style: each pen is its own slot with remembered color.
|
||||
for (final b in kPenToolBrushes)
|
||||
PenSlotButton(
|
||||
kind: b,
|
||||
selected: _tool == EditorToolKind.brush &&
|
||||
_penBrush == b &&
|
||||
_penCaptureEnabled,
|
||||
color: _brushColors[b] ?? Colors.black,
|
||||
tooltip: brushLabel(b, l),
|
||||
onPressed: () {
|
||||
setState(() => _penBrush = b);
|
||||
_setTool(EditorToolKind.brush);
|
||||
},
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
||||
@@ -2222,7 +2457,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (total > 1)
|
||||
if (total > 1 && _showPageScrubber)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
@@ -2238,10 +2473,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
value: (scrub ?? (_pageIndex + 1).toDouble())
|
||||
.clamp(1, total.toDouble()),
|
||||
divisions: total > 1 ? total - 1 : null,
|
||||
onChanged: (v) => setState(() => _pageScrub = v),
|
||||
onChanged: (v) {
|
||||
setState(() => _pageScrub = v);
|
||||
_bumpPageChrome();
|
||||
},
|
||||
onChangeEnd: (v) {
|
||||
setState(() => _pageScrub = null);
|
||||
_goToPage(v.round() - 1);
|
||||
final target = v.round() - 1;
|
||||
// Keep scrub until optimistic _goToPage clears it — no bounce.
|
||||
setState(() => _pageScrub = v);
|
||||
_goToPage(target);
|
||||
setState(() => _showPageScrubber = false);
|
||||
_bumpPageChrome();
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -2259,11 +2501,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
IconButton(
|
||||
tooltip: l.previousPage,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed:
|
||||
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
||||
onPressed: _pageIndex > 0
|
||||
? () {
|
||||
_bumpPageChrome();
|
||||
_goToPage(_pageIndex - 1);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _viewerReady ? _openThumbnails : null,
|
||||
onPressed: () {
|
||||
_bumpPageChrome();
|
||||
if (total > 1) {
|
||||
setState(() => _showPageScrubber = !_showPageScrubber);
|
||||
} else if (_viewerReady) {
|
||||
_openThumbnails();
|
||||
}
|
||||
},
|
||||
onLongPress: _viewerReady ? _openThumbnails : null,
|
||||
child: Text(
|
||||
l.pageOfPages(shown, total),
|
||||
style: TextStyle(
|
||||
@@ -2276,7 +2530,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
tooltip: l.nextPage,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _pageIndex < total - 1
|
||||
? () => _goToPage(_pageIndex + 1)
|
||||
? () {
|
||||
_bumpPageChrome();
|
||||
_goToPage(_pageIndex + 1);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -53,7 +53,7 @@ const double _kScaleGlitchHi = 1.18;
|
||||
/// 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 = 100.0;
|
||||
const double _kFocalGlitchPx = 64.0;
|
||||
|
||||
const double _kDrag = 0.0000135;
|
||||
|
||||
@@ -376,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
_animation!.addListener(_handleInertiaAnimation);
|
||||
_controller.forward();
|
||||
case _GestureType.scale:
|
||||
if (details.scaleVelocity.abs() < 0.1) return;
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
||||
scale,
|
||||
details.scaleVelocity / 10,
|
||||
);
|
||||
final double tFinal = _getFinalTime(
|
||||
details.scaleVelocity.abs(),
|
||||
widget.interactionEndFrictionCoefficient,
|
||||
effectivelyMotionless: 0.1,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: scale,
|
||||
end: frictionSimulation.x(tFinal),
|
||||
).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
|
||||
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
|
||||
_scaleAnimation!.addListener(_handleScaleAnimation);
|
||||
_scaleController.forward();
|
||||
// No scale fling: Windows touch often reports noisy scaleVelocity that
|
||||
// animates past the intended zoom and feels like a "jump" after pinch.
|
||||
return;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -470,12 +470,25 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||
sideButtonAction:
|
||||
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
||||
_penConfig?.value.sideButton ?? PenButtonAction.select,
|
||||
eraserEndAction:
|
||||
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||
allowFingerDrawing: _allowFingerDrawing,
|
||||
onStrokeComplete: _commitStroke,
|
||||
onEraseStroke: _eraseStroke,
|
||||
onPenButtonAction: (action) {
|
||||
if (action == PenButtonAction.select) {
|
||||
setState(() => _tool = EditorToolKind.select);
|
||||
} else if (action == PenButtonAction.undo) {
|
||||
if (_undo.isNotEmpty) _performUndo();
|
||||
} else if (action == PenButtonAction.toggleTool) {
|
||||
setState(() {
|
||||
_tool = _tool == EditorToolKind.eraser
|
||||
? EditorToolKind.brush
|
||||
: EditorToolKind.eraser;
|
||||
});
|
||||
}
|
||||
},
|
||||
// A white sheet with a soft shadow — the note "paper" — overlaid with
|
||||
// the selected background template, painted in page-pixel space (so it
|
||||
// scales with zoom) and BEHIND the ink layers.
|
||||
@@ -510,19 +523,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Pen tool with brush picker (fountain / ballpoint / pencil), each
|
||||
// brush showing its own remembered color.
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == EditorToolKind.brush,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
// OneNote-style: each pen is its own slot with remembered color.
|
||||
for (final b in kPenToolBrushes)
|
||||
PenSlotButton(
|
||||
kind: b,
|
||||
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
||||
color: _brushColors[b] ?? Colors.black,
|
||||
tooltip: brushLabelEn(b),
|
||||
onPressed: () => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == EditorToolKind.highlighter,
|
||||
|
||||
@@ -87,6 +87,64 @@ IconData shapeIcon(ShapeKind kind) => switch (kind) {
|
||||
ShapeKind.arrow => Icons.arrow_outward,
|
||||
};
|
||||
|
||||
/// OneNote-style pen slot: each brush is its own toolbar button with a color
|
||||
/// underline (per-brush remembered color). Prefer this over [BrushPickerButton]
|
||||
/// when the UX wants pens visible side-by-side.
|
||||
class PenSlotButton extends StatelessWidget {
|
||||
const PenSlotButton({
|
||||
super.key,
|
||||
required this.kind,
|
||||
required this.selected,
|
||||
required this.color,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final BrushKind kind;
|
||||
final bool selected;
|
||||
final Color color;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final iconColor =
|
||||
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(brushIcon(kind), size: 22, color: iconColor),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
|
||||
///
|
||||
/// Highlighter and eraser remain separate tools. Tapping the button opens a
|
||||
|
||||
@@ -465,17 +465,18 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == EditorToolKind.brush,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
colorFor: (b) => _brushColors[b] ?? Colors.black,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
// OneNote-style: each pen is its own slot with remembered color.
|
||||
for (final b in kPenToolBrushes)
|
||||
PenSlotButton(
|
||||
kind: b,
|
||||
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
||||
color: _brushColors[b] ?? Colors.black,
|
||||
tooltip: brushLabelEn(b),
|
||||
onPressed: () => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = EditorToolKind.brush;
|
||||
}),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == EditorToolKind.highlighter,
|
||||
@@ -604,8 +605,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
||||
onChanged: (v) => setState(() => _scrub = v),
|
||||
onChangeEnd: (v) {
|
||||
setState(() => _scrub = null);
|
||||
_goToSlide(v.round() - 1);
|
||||
final target = v.round() - 1;
|
||||
setState(() {
|
||||
_scrub = v;
|
||||
_slideIndex = target;
|
||||
});
|
||||
_goToSlide(target);
|
||||
setState(() {
|
||||
_scrub = null;
|
||||
_showSlider = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// 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.
|
||||
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
|
||||
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -16,8 +17,8 @@ import '../engine/brush.dart';
|
||||
import '../input/pen_config.dart' show kDefaultEraserRadius;
|
||||
import '../notebook/ink_stroke_adapter.dart';
|
||||
import '../persistence/sidecar_repository.dart';
|
||||
import 'editor_tool.dart';
|
||||
import 'pen_canvas.dart';
|
||||
import 'pen_palette_widgets.dart';
|
||||
import 'pen_stroke.dart';
|
||||
|
||||
/// Default world size for a fresh sticky scratchpad (absolute px).
|
||||
@@ -31,6 +32,10 @@ class StickyNoteOverlay extends StatefulWidget {
|
||||
required this.repo,
|
||||
required this.onClose,
|
||||
required this.onDelete,
|
||||
this.brush = BrushKind.ballpoint,
|
||||
this.color = const Color(0xFF1A1A1A),
|
||||
this.tool = EditorToolKind.brush,
|
||||
this.allowFingerDrawing = false,
|
||||
});
|
||||
|
||||
final ScratchLink link;
|
||||
@@ -38,6 +43,12 @@ class StickyNoteOverlay extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
/// Shared from the parent PDF toolbar (no mini duplicate palette).
|
||||
final BrushKind brush;
|
||||
final Color color;
|
||||
final EditorToolKind tool;
|
||||
final bool allowFingerDrawing;
|
||||
|
||||
@override
|
||||
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
||||
}
|
||||
@@ -48,12 +59,13 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||
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;
|
||||
|
||||
/// On-screen card size (user-resizable). World canvas stays [_world].
|
||||
double _cardW = 300;
|
||||
double _cardH = 360;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -61,13 +73,16 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||
if (pad != null) {
|
||||
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
||||
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||
// Prefer a card that roughly matches aspect of the world, clamped.
|
||||
final aspect = _world.width / math.max(_world.height, 1);
|
||||
_cardW = (280.0 * aspect).clamp(220.0, 520.0);
|
||||
_cardH = (_cardW / aspect + 40).clamp(260.0, 640.0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveTimer?.cancel();
|
||||
// Best-effort sync save before leaving the overlay.
|
||||
if (_dirty) {
|
||||
widget.repo.scheduleScratchpadSave(
|
||||
widget.link.id,
|
||||
@@ -134,6 +149,32 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||
widget.onClose();
|
||||
}
|
||||
|
||||
CanvasTool get _canvasTool {
|
||||
switch (widget.tool) {
|
||||
case EditorToolKind.eraser:
|
||||
return CanvasTool.eraser;
|
||||
case EditorToolKind.select:
|
||||
return CanvasTool.select;
|
||||
case EditorToolKind.highlighter:
|
||||
case EditorToolKind.brush:
|
||||
case EditorToolKind.shape:
|
||||
case EditorToolKind.text:
|
||||
return CanvasTool.pen;
|
||||
}
|
||||
}
|
||||
|
||||
BrushKind get _canvasBrush =>
|
||||
widget.tool == EditorToolKind.highlighter
|
||||
? BrushKind.highlighter
|
||||
: widget.brush;
|
||||
|
||||
void _onResizeDrag(DragUpdateDetails d) {
|
||||
setState(() {
|
||||
_cardW = (_cardW + d.delta.dx).clamp(220.0, 640.0);
|
||||
_cardH = (_cardH + d.delta.dy).clamp(240.0, 720.0);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
@@ -142,117 +183,96 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: const Color(0xFFFFF8E1),
|
||||
child: SizedBox(
|
||||
width: 280,
|
||||
height: 340,
|
||||
child: Column(
|
||||
width: _cardW,
|
||||
height: _cardH,
|
||||
child: Stack(
|
||||
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,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFFFE082),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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)),
|
||||
Text(
|
||||
'用顶栏笔/色',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: cs.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ClipRect(
|
||||
child: PenCanvas(
|
||||
pageSize: _world,
|
||||
strokes: penStrokesFromInk(_strokes, _world),
|
||||
transformationController: _transform,
|
||||
tool: _canvasTool,
|
||||
brush: _canvasBrush,
|
||||
color: widget.color,
|
||||
strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction,
|
||||
eraserRadius: kDefaultEraserRadius,
|
||||
allowFingerDrawing: widget.allowFingerDrawing,
|
||||
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,
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: _onResizeDrag,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeUpLeftDownRight,
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Icon(
|
||||
Icons.south_east,
|
||||
size: 16,
|
||||
color: cs.onSurface.withValues(alpha: 0.45),
|
||||
),
|
||||
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