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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -135,16 +135,15 @@ class BrushProfile {
|
||||
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
|
||||
/// the fountainPen/highlighter presets (no regression).
|
||||
const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||
// Fountain pen — spec §4: size~6, thinning 0.9, smoothing 0.55,
|
||||
// streamline 0.45, simulatePressure false, taper on, pressure pre-warped to
|
||||
// p² (Pow2 / quadratic = pressureGamma 2.0). Solid ink (opacity 1.0).
|
||||
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
|
||||
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
|
||||
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
|
||||
BrushKind.fountainPen: BrushProfile(
|
||||
kind: BrushKind.fountainPen,
|
||||
baseWidthFraction: 0.006,
|
||||
pressureGamma: 2.0,
|
||||
// Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph).
|
||||
pfThinning: 0.65,
|
||||
pfStreamline: 0.4,
|
||||
pfThinning: 0.75,
|
||||
pfStreamline: 0.22,
|
||||
pfSmoothing: 0.5,
|
||||
simulatePressure: false,
|
||||
capStart: true,
|
||||
@@ -154,15 +153,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||
opacity: 1.0,
|
||||
blendMultiply: false,
|
||||
),
|
||||
// Ballpoint — Krita-inspired "ink pen": near-constant width, SOLID opacity.
|
||||
// Pressure modulates WIDTH slightly (thinning 0.15), NOT alpha — translucent
|
||||
// srcOver stacking looked like accidental multiply when strokes overlapped.
|
||||
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
|
||||
// for lower latency; thinning 0.12 keeps width almost flat.
|
||||
BrushKind.ballpoint: BrushProfile(
|
||||
kind: BrushKind.ballpoint,
|
||||
baseWidthFraction: 0.0022,
|
||||
pressureGamma: 1.0,
|
||||
pfThinning: 0.15,
|
||||
pfStreamline: 0.55,
|
||||
pfThinning: 0.12,
|
||||
pfStreamline: 0.35,
|
||||
pfSmoothing: 0.5,
|
||||
simulatePressure: false,
|
||||
capStart: true,
|
||||
@@ -171,18 +169,14 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||
opacity: 1.0,
|
||||
blendMultiply: false,
|
||||
),
|
||||
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
|
||||
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
|
||||
// multiply build-up. opacity 0.35 / blendMultiply true are APPLIED via
|
||||
// resolveStrokePaint: the 0.35 is multiplied INTO the color's existing alpha
|
||||
// (the capture path ships a 0x80 / 50% translucent color), and the stroke
|
||||
// composites with BlendMode.multiply (cross-stroke overlap darkens = marker).
|
||||
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
|
||||
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
|
||||
BrushKind.highlighter: BrushProfile(
|
||||
kind: BrushKind.highlighter,
|
||||
baseWidthFraction: 0.02,
|
||||
pressureGamma: 1.0,
|
||||
pfThinning: 0.0,
|
||||
pfStreamline: 0.5,
|
||||
pfStreamline: 0.3,
|
||||
pfSmoothing: 0.4,
|
||||
simulatePressure: false,
|
||||
capStart: false,
|
||||
@@ -191,15 +185,13 @@ const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||
opacity: 0.35,
|
||||
blendMultiply: true,
|
||||
),
|
||||
// Pencil — Krita-inspired: soft graphite, moderate translucency via √p, but
|
||||
// NEVER multiply blend (only highlighter uses multiply). Cap ~0.88 so overlaps
|
||||
// darken gently under srcOver without turning into marker blobs.
|
||||
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
|
||||
BrushKind.pencil: BrushProfile(
|
||||
kind: BrushKind.pencil,
|
||||
baseWidthFraction: 0.003,
|
||||
pressureGamma: 0.5,
|
||||
pfThinning: 0.45,
|
||||
pfStreamline: 0.35,
|
||||
pfStreamline: 0.25,
|
||||
pfSmoothing: 0.45,
|
||||
simulatePressure: false,
|
||||
capStart: true,
|
||||
|
||||
33
lib/editor/engine/pen_physics.dart
Normal file
33
lib/editor/engine/pen_physics.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
// lib/editor/engine/pen_physics.dart
|
||||
//
|
||||
// Simple physical tip model: modulate stroke width by tip velocity so fountain
|
||||
// ink feels slightly thinner when moving fast (starvation), while ballpoint
|
||||
// stays nearly velocity-invariant.
|
||||
//
|
||||
// TODO(pen-physics-wire): wired at capture in PenCanvas._toNormalized via
|
||||
// tip velocity × pressure. PDF editor path still uses brush gamma only.
|
||||
|
||||
import 'brush.dart';
|
||||
|
||||
/// Modulate width fraction by tip velocity (page-normalized units per second).
|
||||
///
|
||||
/// Fountain: faster → slightly thinner (ink starvation feel).
|
||||
/// Ballpoint: nearly ignore velocity.
|
||||
/// Pencil: mild thinning at speed.
|
||||
/// Highlighter: ignore velocity (flat marker).
|
||||
double tipVelocityWidthScale(BrushKind kind, double speedNormPerSec) {
|
||||
final speed =
|
||||
speedNormPerSec.isNaN || speedNormPerSec < 0 ? 0.0 : speedNormPerSec;
|
||||
// Reference: ~2 page-widths/sec ≈ fast handwriting; clamp influence to [0,1].
|
||||
final t = (speed / 2.0).clamp(0.0, 1.0);
|
||||
switch (kind) {
|
||||
case BrushKind.fountainPen:
|
||||
return 1.0 - 0.15 * t;
|
||||
case BrushKind.ballpoint:
|
||||
return 1.0 - 0.02 * t;
|
||||
case BrushKind.pencil:
|
||||
return 1.0 - 0.08 * t;
|
||||
case BrushKind.highlighter:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class PredictedPoint {
|
||||
}
|
||||
|
||||
class StrokePredictor {
|
||||
StrokePredictor({this.lookaheadMs = 12});
|
||||
StrokePredictor({this.lookaheadMs = 8});
|
||||
|
||||
/// How far ahead to project, in milliseconds of recent velocity.
|
||||
final double lookaheadMs;
|
||||
|
||||
@@ -19,7 +19,10 @@ enum PenButtonAction {
|
||||
toggleTool,
|
||||
pan,
|
||||
|
||||
/// Hold to temporarily enable PDF text selection (OneNote-style).
|
||||
/// Rising-edge: switch to the universal stroke [select] tool (OneNote-like).
|
||||
select,
|
||||
|
||||
/// Hold to temporarily enable PDF text selection.
|
||||
selectText,
|
||||
}
|
||||
|
||||
@@ -28,7 +31,7 @@ enum PenButtonAction {
|
||||
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
||||
class PenConfig {
|
||||
const PenConfig({
|
||||
this.sideButton = PenButtonAction.eraser,
|
||||
this.sideButton = PenButtonAction.select,
|
||||
this.eraserEnd = PenButtonAction.eraser,
|
||||
this.pressureGamma = kNaturalPressureGamma,
|
||||
this.palmRejectionMs = 150.0,
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
||||
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
|
||||
//
|
||||
// Named [PressureCurveShape] presets mirror rnote-style curves (linear / soft /
|
||||
// Pow2 / cubic / log / sqrt) via [PressureCurve.shaped]. Logarithmic uses
|
||||
// ln(1+k·p)/ln(1+k); all others use p^gamma.
|
||||
//
|
||||
// Pure value type (widget-free, storage-free) so the full mapping is unit
|
||||
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
||||
// draw path and is validated on-device).
|
||||
@@ -23,27 +27,86 @@ const double kNaturalPressureGamma = 0.7;
|
||||
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
|
||||
const double kNaturalPressureFloor = 0.12;
|
||||
|
||||
/// Steepness for [PressureCurveShape.logarithmic]: `ln(1+k·p)/ln(1+k)`.
|
||||
const double kLogarithmicPressureK = 9.0;
|
||||
|
||||
/// Named rnote-style pressure-response shapes.
|
||||
enum PressureCurveShape {
|
||||
/// Identity: gamma 1.
|
||||
linear,
|
||||
|
||||
/// Light-touch sensitive: gamma ≈ 0.6.
|
||||
soft,
|
||||
|
||||
/// rnote Pow2 / fountain: gamma 2.
|
||||
quadratic,
|
||||
|
||||
/// gamma 3.
|
||||
cubic,
|
||||
|
||||
/// Log curve: ln(1+k·p)/ln(1+k).
|
||||
logarithmic,
|
||||
|
||||
/// Pencil: gamma 0.5.
|
||||
sqrt,
|
||||
}
|
||||
|
||||
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
|
||||
class PressureCurve {
|
||||
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
|
||||
: assert(floor >= 0.0 && floor < 1.0),
|
||||
const PressureCurve({
|
||||
this.floor = 0.0,
|
||||
this.gamma = 1.0,
|
||||
this.shape,
|
||||
}) : assert(floor >= 0.0 && floor < 1.0),
|
||||
assert(gamma > 0.0);
|
||||
|
||||
/// Named-shape factory. Sets [gamma] for power-law shapes; logarithmic
|
||||
/// ignores gamma and uses [kLogarithmicPressureK] in [apply].
|
||||
factory PressureCurve.shaped(
|
||||
PressureCurveShape shape, {
|
||||
double floor = 0.0,
|
||||
}) {
|
||||
switch (shape) {
|
||||
case PressureCurveShape.linear:
|
||||
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||
case PressureCurveShape.soft:
|
||||
return PressureCurve(floor: floor, gamma: 0.6, shape: shape);
|
||||
case PressureCurveShape.quadratic:
|
||||
return PressureCurve(floor: floor, gamma: 2.0, shape: shape);
|
||||
case PressureCurveShape.cubic:
|
||||
return PressureCurve(floor: floor, gamma: 3.0, shape: shape);
|
||||
case PressureCurveShape.logarithmic:
|
||||
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||
case PressureCurveShape.sqrt:
|
||||
return PressureCurve(floor: floor, gamma: 0.5, shape: shape);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
|
||||
/// fixed-pressure feel (marker).
|
||||
final double floor;
|
||||
|
||||
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
||||
/// >1 = firmer.
|
||||
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
|
||||
final double gamma;
|
||||
|
||||
/// Optional named shape. When [PressureCurveShape.logarithmic], [apply] uses
|
||||
/// the log formula; otherwise (or when null) uses `p^gamma`.
|
||||
final PressureCurveShape? shape;
|
||||
|
||||
/// Linear, full-range pen response (identity).
|
||||
static const PressureCurve linear = PressureCurve();
|
||||
|
||||
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
||||
double apply(double pressure) {
|
||||
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
||||
final shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||
final double shaped;
|
||||
if (shape == PressureCurveShape.logarithmic) {
|
||||
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
|
||||
math.log(1.0 + kLogarithmicPressureK);
|
||||
} else {
|
||||
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||
}
|
||||
return floor + (1.0 - floor) * shaped;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pressure_curve.dart';
|
||||
|
||||
String _shapeNameForGamma(double gamma) {
|
||||
if ((gamma - 0.6).abs() < 0.05) return 'soft';
|
||||
if ((gamma - 1.0).abs() < 0.05) return 'linear';
|
||||
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
|
||||
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
|
||||
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
|
||||
return 'soft';
|
||||
}
|
||||
|
||||
PressureCurve _curveForName(String name) => switch (name) {
|
||||
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
|
||||
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
|
||||
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
|
||||
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
|
||||
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
|
||||
_ => PressureCurve.shaped(PressureCurveShape.soft),
|
||||
};
|
||||
|
||||
/// Shows a Material You modal bottom sheet for configuring pen input.
|
||||
///
|
||||
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
|
||||
formatValue: (v) => v.toStringAsFixed(2),
|
||||
onChanged: controller.setPressureGamma,
|
||||
),
|
||||
_LabeledRow(
|
||||
label: 'Curve Preset (rnote)',
|
||||
child: DropdownMenu<String>(
|
||||
initialSelection: _shapeNameForGamma(config.pressureGamma),
|
||||
onSelected: (name) {
|
||||
if (name == null) return;
|
||||
final shaped = _curveForName(name);
|
||||
controller.setPressureGamma(shaped.gamma);
|
||||
},
|
||||
dropdownMenuEntries: const [
|
||||
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
|
||||
DropdownMenuEntry(value: 'linear', label: 'Linear'),
|
||||
DropdownMenuEntry(
|
||||
value: 'quadratic', label: 'Quadratic / Pow2'),
|
||||
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
|
||||
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Text(
|
||||
'笔刷自带曲线优先(钢笔=二次/Pow2,铅笔=平方根)。全局 gamma 作后备。',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────
|
||||
_SectionHeader(
|
||||
@@ -265,12 +310,13 @@ class _ActionDropdown extends StatelessWidget {
|
||||
final ValueChanged<PenButtonAction> onChanged;
|
||||
|
||||
static String _label(PenButtonAction action) => switch (action) {
|
||||
PenButtonAction.none => 'None',
|
||||
PenButtonAction.eraser => 'Eraser',
|
||||
PenButtonAction.undo => 'Undo',
|
||||
PenButtonAction.toggleTool => 'Toggle Tool',
|
||||
PenButtonAction.pan => 'Pan',
|
||||
PenButtonAction.selectText => 'Select text',
|
||||
PenButtonAction.none => '无',
|
||||
PenButtonAction.eraser => '橡皮',
|
||||
PenButtonAction.undo => '撤销',
|
||||
PenButtonAction.toggleTool => '切换工具',
|
||||
PenButtonAction.pan => '平移',
|
||||
PenButtonAction.select => '选择(笔迹)',
|
||||
PenButtonAction.selectText => '选择文本',
|
||||
};
|
||||
|
||||
@override
|
||||
|
||||
@@ -218,11 +218,20 @@ class _MemberTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(member.kind.name.toUpperCase()),
|
||||
subtitle: Text(_kindLabel(member.kind)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
String _kindLabel(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => '空白页 · 手写',
|
||||
NotebookMemberKind.pdf => 'PDF · 批注',
|
||||
NotebookMemberKind.pptx => 'PPTX · 幻灯片',
|
||||
NotebookMemberKind.ppt => 'PPT · 幻灯片',
|
||||
NotebookMemberKind.docx => 'DOCX · 文档',
|
||||
};
|
||||
|
||||
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => Icons.edit_note,
|
||||
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
||||
|
||||
Reference in New Issue
Block a user