fix: soft-clamp pinch zoom and Krita-inspired brush opacity
All checks were successful
CI / Windows build (push) Successful in 10m28s

Hard SDROP avalanches froze lastRaw while zoom still crawled; soft-clamp
and re-anchor instead. Ballpoint is near-solid, pencil uses soft √p without
multiply stacking; PDF ink falls back to nearest page during zoom settle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:51:15 +08:00
parent 2b1c6ba7e0
commit 4f6fb69dee
7 changed files with 267 additions and 136 deletions

View File

@@ -169,7 +169,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
/// Logs showed ~1.30 spikes — keep the band below that.
static const double _kScaleGlitchHi = 1.18;
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
static const double _kFocalGlitchPx = 100.0;
@@ -181,9 +180,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// Pointer count of the previous accepted pinch frame; a change re-baselines.
int _pinchPointerCount = 0;
/// The recognizer's cumulative `details.scale` on the previous accepted frame.
double _pinchLastRawScale = 1.0;
/// The absolute scale we last APPLIED. Re-baseline anchors to THIS (not a live
/// matrix read) so the displayed scale stays continuous across a finger blip.
double _pinchLastAppliedScale = 1.0;
@@ -575,14 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return null;
}
/// Map a global pen position to (pageIndex, normalized-in-page) using the
/// controller's document-space page layout rects. Returns null if outside
/// every page box or the viewer isn't ready.
/// Map a global pen position to (pageIndex, normalized-in-page). During a
/// zoom glitch `globalToDocument` can miss every page rect — fall back to the
/// nearest page so strokes do not silently vanish mid-gesture.
({int page, Offset normalized})? _documentToPage(Offset global) {
if (!_controller.isReady) return null;
final doc = _controller.globalToDocument(global);
if (doc == null) return null;
final rects = _controller.layout.pageLayouts;
if (rects.isEmpty) return null;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
@@ -591,7 +589,24 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return (page: i, normalized: Offset(nx, ny));
}
}
return null;
// Nearest-page fallback (common while pinch is settling).
var bestI = 0;
var bestDist = double.infinity;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
final cx = doc.dx.clamp(r.left, r.right);
final cy = doc.dy.clamp(r.top, r.bottom);
final d = (Offset(cx, cy) - doc).distanceSquared;
if (d < bestDist) {
bestDist = d;
bestI = i;
}
}
final r = rects[bestI];
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return (page: bestI, normalized: Offset(nx, ny));
}
/// Hit-test scratch-link markers near [normalized] on [page].
@@ -894,7 +909,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (!_controller.isReady) return;
_pinchScaleStart = _controller.currentZoom;
_pinchPointerCount = details.pointerCount;
_pinchLastRawScale = 1.0;
_pinchLastAppliedScale = _pinchScaleStart!;
_pinchRawScaleAtBaseline = 1.0;
}
@@ -909,63 +923,73 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (details.pointerCount != _pinchPointerCount) {
_pinchPointerCount = details.pointerCount;
_pinchScaleStart = _pinchLastAppliedScale;
_pinchLastRawScale = details.scale;
_pinchRawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
// Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
// A ratio outside the glitch band is a multi-touch spike → drop the frame;
// absolute tracking means the next good frame resumes from the true span.
final rawRatio =
_pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
final scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
// Soft-clamp per-step change (Surface diag: hard SDROP avalanche when
// lastRaw froze while live zoom still crawled). Always apply + advance.
final step = softClampedPinchStep(
scaleStart: _pinchScaleStart!,
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
rawScale: details.scale,
lastAppliedScale: _pinchLastAppliedScale,
minScale: _kPinchMinScale,
maxScale: _kPinchMaxScale,
maxStepRatio: _kScaleGlitchHi,
);
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
if (scaleDrop || focalDrop) {
if (focalDrop) {
// Keep scale continuous; only skip the focal jump this frame.
if (step.reanchor) {
_pinchScaleStart = step.appliedScale;
_pinchRawScaleAtBaseline = details.scale;
_pinchLastAppliedScale = step.appliedScale;
}
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: _pinchLastAppliedScale,
appliedChange: 1.0,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: scaleDrop,
focalDrop: focalDrop,
scaleDrop: step.spiked,
focalDrop: true,
);
return;
}
final targetScale = absolutePinchScale(
scaleStart: _pinchScaleStart!,
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
rawScale: details.scale,
minScale: _kPinchMinScale,
maxScale: _kPinchMaxScale,
);
final targetScale = step.appliedScale;
if (step.reanchor) {
_pinchScaleStart = targetScale;
_pinchRawScaleAtBaseline = details.scale;
}
// Focal zoom: keep the document point under the live focal (finger midpoint)
// fixed, which also yields 2-finger pan for free as the focal moves.
// localFocalPoint is in the viewer's local coords (the overlay fills it).
_controller.zoomOnLocalPosition(
localPosition: details.localFocalPoint,
newZoom: targetScale,
duration: Duration.zero,
);
final applied =
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
// Prefer controller read-back so we stay locked to what pdfrx actually
// applied (guards against a second consumer nudging zoom).
final live = _controller.currentZoom;
final appliedScale = live > 0 ? live : targetScale;
final applied = _pinchLastAppliedScale > 0
? appliedScale / _pinchLastAppliedScale
: 1.0;
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: targetScale,
currentScale: appliedScale,
appliedChange: applied,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: false,
scaleDrop: step.spiked,
focalDrop: false,
);
_pinchLastRawScale = details.scale;
_pinchLastAppliedScale = targetScale;
_pinchLastAppliedScale = appliedScale;
}
void _onPinchEnd(ScaleEndDetails details) {