fix(pdf): live ink follows pen + stop zoom jump
All checks were successful
CI / Windows build (push) Successful in 12m51s
All checks were successful
CI / Windows build (push) Successful in 12m51s
Two critical PDF-editor bugs. 1. Live ink only appeared after lifting the pen. The page overlay painter captured the live stroke as a build-time snapshot, so per-move repaints redrew stale (null) data until commit. Route the live stroke through a ValueNotifier the painter reads at paint time (repaint: merge(overlayRepaint, liveStrokeVN)). 2. Pinch-zoom jumped on Windows touch. pdfrx's internal forked InteractiveViewer scales with an unguarded scaleStart*details.scale that pops on a touch-count blip or one-frame spike. Take over the pinch: scaleEnabled:false (pdfrx keeps 1-finger scroll + wheel), a glitch-guarded ScaleGestureRecognizer drives focal zoom via the pdfrx controller, reusing absolutePinchScale + the re-baseline / per-frame-clamp / focal-jump guards already proven on the note canvas. Zoom + pen feel are device-validated. analyze clean, tests green.
This commit is contained in:
@@ -20,7 +20,9 @@
|
|||||||
// wiring verbatim. Highlights are in-memory only for now — see
|
// wiring verbatim. Highlights are in-memory only for now — see
|
||||||
// TODO(persist-highlights).
|
// TODO(persist-highlights).
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
import 'package:flutter/foundation.dart'
|
||||||
|
show ValueListenable, visibleForTesting;
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -50,6 +52,7 @@ import 'input_diagnostics.dart';
|
|||||||
import 'pen_canvas.dart' show CanvasTool;
|
import 'pen_canvas.dart' show CanvasTool;
|
||||||
import 'pen_palette_widgets.dart';
|
import 'pen_palette_widgets.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
import 'pinch_scale_solver.dart';
|
||||||
|
|
||||||
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
||||||
///
|
///
|
||||||
@@ -132,6 +135,42 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
final ValueNotifier<int> _overlayRepaint = ValueNotifier<int>(0);
|
final ValueNotifier<int> _overlayRepaint = ValueNotifier<int>(0);
|
||||||
void _bumpOverlay() => _overlayRepaint.value++;
|
void _bumpOverlay() => _overlayRepaint.value++;
|
||||||
|
|
||||||
|
// ── Glitch-guarded pinch zoom (we own scale; pdfrx owns scroll) ─────────────
|
||||||
|
// pdfrx's scaleEnabled is off, so we drive 2-finger zoom through the public
|
||||||
|
// controller (zoomOnLocalPosition) with the SAME guards as the note canvas's
|
||||||
|
// PenInteractiveViewer: per-frame scale-ratio clamp, pointer-count re-baseline
|
||||||
|
// to the last APPLIED scale, and a focal-jump guard. Tracking is absolute from
|
||||||
|
// a gesture-start snapshot via absolutePinchScale() — never a live read-back.
|
||||||
|
|
||||||
|
static const double _kPinchMinScale = 0.5;
|
||||||
|
static const double _kPinchMaxScale = 8.0;
|
||||||
|
|
||||||
|
/// A real pinch changes scale modestly per frame; a frame demanding far more
|
||||||
|
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
|
||||||
|
static const double _kScaleGlitchHi = 1.4;
|
||||||
|
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
||||||
|
|
||||||
|
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
||||||
|
static const double _kFocalGlitchPx = 250.0;
|
||||||
|
|
||||||
|
/// Matrix scale captured at the current baseline (gesture start or the last
|
||||||
|
/// pointer-count re-baseline). Null when no pinch is active.
|
||||||
|
double? _pinchScaleStart;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
/// The recognizer's cumulative `details.scale` at the current baseline; the
|
||||||
|
/// absolute target normalizes against it (see [absolutePinchScale]).
|
||||||
|
double _pinchRawScaleAtBaseline = 1.0;
|
||||||
|
|
||||||
// ── Live stroke state (viewer-level pen capture) ────────────────────────────
|
// ── Live stroke state (viewer-level pen capture) ────────────────────────────
|
||||||
|
|
||||||
/// The page index the in-progress stroke belongs to (the page of its first
|
/// The page index the in-progress stroke belongs to (the page of its first
|
||||||
@@ -142,8 +181,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// In-progress stroke points (normalized to [_liveStrokePage]).
|
/// In-progress stroke points (normalized to [_liveStrokePage]).
|
||||||
final List<PenPoint> _livePoints = [];
|
final List<PenPoint> _livePoints = [];
|
||||||
|
|
||||||
/// Live stroke snapshot for the page overlay; null when idle.
|
/// CURRENT live stroke, published to the page overlay painters. The painter
|
||||||
PenStroke? _liveStroke;
|
/// reads this at PAINT time (not as a build-time snapshot), so a mid-stroke
|
||||||
|
/// update repaints immediately — the ink follows the pen instead of only
|
||||||
|
/// appearing on pointer-up. Null when idle.
|
||||||
|
///
|
||||||
|
/// (The previous design passed a snapshot into the painter's constructor from
|
||||||
|
/// pageOverlaysBuilder, which only re-runs on setState; _bumpOverlay repainted
|
||||||
|
/// the painter but it still read the stale build-time snapshot → invisible ink
|
||||||
|
/// until commit. See `_LiveStrokeData` / `_PageOverlayPainter`.)
|
||||||
|
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
|
||||||
|
|
||||||
/// Latest pen-event debug readout — shown only when the diagnostic toggle is
|
/// Latest pen-event debug readout — shown only when the diagnostic toggle is
|
||||||
/// on, to inspect what Windows delivers.
|
/// on, to inspect what Windows delivers.
|
||||||
@@ -310,6 +357,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
scheduler.dispose();
|
scheduler.dispose();
|
||||||
}
|
}
|
||||||
_overlayRepaint.dispose();
|
_overlayRepaint.dispose();
|
||||||
|
_liveStrokeVN.dispose();
|
||||||
_penConfig?.dispose();
|
_penConfig?.dispose();
|
||||||
PenInputService.instance.stop();
|
PenInputService.instance.stop();
|
||||||
DiagnosticLogger.instance.stop();
|
DiagnosticLogger.instance.stop();
|
||||||
@@ -481,14 +529,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
void _updateLiveStroke() {
|
void _updateLiveStroke() {
|
||||||
final page = _liveStrokePage;
|
final page = _liveStrokePage;
|
||||||
if (page == null || _livePoints.isEmpty) return;
|
if (page == null || _livePoints.isEmpty) return;
|
||||||
_liveStroke = PenStroke(
|
final stroke = PenStroke(
|
||||||
points: List.of(_livePoints),
|
points: List.of(_livePoints),
|
||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: _currentStrokeWidth(),
|
width: _currentStrokeWidth(),
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
brush: _currentBrush(),
|
brush: _currentBrush(),
|
||||||
);
|
);
|
||||||
_bumpOverlay();
|
// Publish the live stroke so the page overlay painter repaints it NOW
|
||||||
|
// (notifies its repaint Listenable) — the ink follows the pen.
|
||||||
|
_liveStrokeVN.value = _LiveStrokeData(page, stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endStroke({required bool commit}) {
|
void _endStroke({required bool commit}) {
|
||||||
@@ -509,7 +559,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
_liveStrokePage = null;
|
_liveStrokePage = null;
|
||||||
_livePoints.clear();
|
_livePoints.clear();
|
||||||
_liveStroke = null;
|
_liveStrokeVN.value = null;
|
||||||
_bumpOverlay();
|
_bumpOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,6 +632,68 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pinch zoom (glitch-guarded, drives pdfrx controller) ────────────────────
|
||||||
|
|
||||||
|
void _onPinchStart(ScaleStartDetails details) {
|
||||||
|
if (!_controller.isReady) return;
|
||||||
|
_pinchScaleStart = _controller.currentZoom;
|
||||||
|
_pinchPointerCount = details.pointerCount;
|
||||||
|
_pinchLastRawScale = 1.0;
|
||||||
|
_pinchLastAppliedScale = _pinchScaleStart!;
|
||||||
|
_pinchRawScaleAtBaseline = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPinchUpdate(ScaleUpdateDetails details) {
|
||||||
|
final scaleStart = _pinchScaleStart;
|
||||||
|
if (scaleStart == null || !_controller.isReady) return;
|
||||||
|
|
||||||
|
// Re-baseline on any pointer-count change (a finger lands/lifts, or a
|
||||||
|
// Windows touch 2↔1↔2 dropout). Anchor to the CLEAN tracked scale, not a
|
||||||
|
// matrix read-back, so the displayed scale is continuous; skip this frame.
|
||||||
|
if (details.pointerCount != _pinchPointerCount) {
|
||||||
|
_pinchPointerCount = details.pointerCount;
|
||||||
|
_pinchScaleStart = _pinchLastAppliedScale;
|
||||||
|
_pinchLastRawScale = details.scale;
|
||||||
|
_pinchRawScaleAtBaseline = details.scale;
|
||||||
|
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;
|
||||||
|
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
|
||||||
|
if (scaleDrop || focalDrop) return;
|
||||||
|
|
||||||
|
final targetScale = absolutePinchScale(
|
||||||
|
scaleStart: _pinchScaleStart!,
|
||||||
|
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
|
||||||
|
rawScale: details.scale,
|
||||||
|
minScale: _kPinchMinScale,
|
||||||
|
maxScale: _kPinchMaxScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pinchLastRawScale = details.scale;
|
||||||
|
_pinchLastAppliedScale = targetScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPinchEnd(ScaleEndDetails details) {
|
||||||
|
_pinchScaleStart = null;
|
||||||
|
_pinchPointerCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Text selection → highlight ─────────────────────────────────────────────
|
// ── Text selection → highlight ─────────────────────────────────────────────
|
||||||
|
|
||||||
void _onTextSelectionChange(PdfTextSelection selection) {
|
void _onTextSelectionChange(PdfTextSelection selection) {
|
||||||
@@ -804,6 +916,15 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
widget.pdfPath,
|
widget.pdfPath,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
params: PdfViewerParams(
|
params: PdfViewerParams(
|
||||||
|
// pdfrx keeps 1-finger scroll + wheel (panEnabled) but we TAKE OVER the
|
||||||
|
// 2-finger pinch (scaleEnabled:false): pdfrx's forked InteractiveViewer
|
||||||
|
// applies `desiredScale = _scaleStart * details.scale` with no per-frame
|
||||||
|
// glitch guard, so a Windows-touch scale spike or pointer-count blip pops
|
||||||
|
// 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,
|
||||||
|
scaleEnabled: false,
|
||||||
// Native vector text selection. Pen falls through to this only in
|
// Native vector text selection. Pen falls through to this only in
|
||||||
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
// select-text mode (PenCaptureRegion.captureEnabled == false).
|
||||||
textSelectionParams: PdfTextSelectionParams(
|
textSelectionParams: PdfTextSelectionParams(
|
||||||
@@ -840,11 +961,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _PageOverlayPainter(
|
painter: _PageOverlayPainter(
|
||||||
repaint: _overlayRepaint,
|
overlayRepaint: _overlayRepaint,
|
||||||
|
liveStrokeVN: _liveStrokeVN,
|
||||||
|
pageIndex: pageIndex,
|
||||||
strokes: _strokesByPage[pageIndex] ?? const [],
|
strokes: _strokesByPage[pageIndex] ?? const [],
|
||||||
highlights: _highlightsByPage[pageIndex] ?? const [],
|
highlights: _highlightsByPage[pageIndex] ?? const [],
|
||||||
liveStroke:
|
|
||||||
_liveStrokePage == pageIndex ? _liveStroke : null,
|
|
||||||
pageSize: pageRectInViewer.size,
|
pageSize: pageRectInViewer.size,
|
||||||
thinning: _penConfig?.value.pressureSensitivity ??
|
thinning: _penConfig?.value.pressureSensitivity ??
|
||||||
kDefaultPenThinning,
|
kDefaultPenThinning,
|
||||||
@@ -883,11 +1004,32 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
// (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool
|
// (2) Viewer-level pen capture + our glitch-guarded pinch. Stylus is
|
||||||
// is active; touch/mouse (and pen in select-text mode) fall through to
|
// captured ONLY when a pen tool is active; touch/mouse (and pen in
|
||||||
// pdfrx for scroll/zoom/text-selection.
|
// select-text mode) fall through to pdfrx for scroll/text-selection.
|
||||||
|
// The pinch recognizer is touch-only and concedes the arena below 2
|
||||||
|
// pointers, so 1-finger scroll still reaches pdfrx and the pen (captured
|
||||||
|
// outside the arena by PenCaptureRegion) is never stolen.
|
||||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
||||||
return [
|
return [
|
||||||
|
Positioned.fill(
|
||||||
|
child: RawGestureDetector(
|
||||||
|
// translucent (NOT opaque): the touch must ALSO hit-test pdfrx
|
||||||
|
// underneath so its pan recognizer can win the 1-finger case.
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
gestures: {
|
||||||
|
_TwoFingerPinch:
|
||||||
|
GestureRecognizerFactoryWithHandlers<_TwoFingerPinch>(
|
||||||
|
() => _TwoFingerPinch(debugOwner: this),
|
||||||
|
(r) => r
|
||||||
|
..onStart = _onPinchStart
|
||||||
|
..onUpdate = _onPinchUpdate
|
||||||
|
..onEnd = _onPinchEnd,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: PenCaptureRegion(
|
child: PenCaptureRegion(
|
||||||
captureEnabled: _penCaptureEnabled,
|
captureEnabled: _penCaptureEnabled,
|
||||||
@@ -1151,23 +1293,88 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A [ScaleGestureRecognizer] that competes ONLY for 2-finger touch gestures.
|
||||||
|
///
|
||||||
|
/// pdfrx's internal pan recognizer runs underneath (scaleEnabled is off, so it
|
||||||
|
/// still scrolls on 1 finger). A vanilla ScaleGestureRecognizer is eager: with a
|
||||||
|
/// single uncontested pointer it would win the arena and STEAL 1-finger scroll.
|
||||||
|
/// Restricting [supportedDevices] to touch keeps the stylus out (the pen is
|
||||||
|
/// captured outside the arena by [PenCaptureRegion]); rejecting the pointer while
|
||||||
|
/// fewer than 2 are down hands the 1-finger gesture back to pdfrx immediately,
|
||||||
|
/// and accepting on the 2nd finger lets us drive the pinch. Guarding inside
|
||||||
|
/// onUpdate would be too late — by then the arena is already won.
|
||||||
|
class _TwoFingerPinch extends ScaleGestureRecognizer {
|
||||||
|
_TwoFingerPinch({super.debugOwner})
|
||||||
|
: super(supportedDevices: const {PointerDeviceKind.touch});
|
||||||
|
|
||||||
|
final Set<int> _pointers = <int>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void addAllowedPointer(PointerDownEvent event) {
|
||||||
|
_pointers.add(event.pointer);
|
||||||
|
super.addAllowedPointer(event);
|
||||||
|
if (_pointers.length < 2) {
|
||||||
|
// Concede the 1-finger case now so pdfrx's pan wins without waiting.
|
||||||
|
resolvePointer(event.pointer, GestureDisposition.rejected);
|
||||||
|
} else {
|
||||||
|
// Second finger down: claim the gesture before pdfrx treats it as a pan.
|
||||||
|
resolve(GestureDisposition.accepted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleEvent(PointerEvent event) {
|
||||||
|
if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||||
|
_pointers.remove(event.pointer);
|
||||||
|
}
|
||||||
|
super.handleEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void rejectGesture(int pointer) {
|
||||||
|
_pointers.remove(pointer);
|
||||||
|
super.rejectGesture(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently in-progress stroke and the page it belongs to. Published via a
|
||||||
|
/// [ValueNotifier] so [_PageOverlayPainter] can read the live stroke at PAINT
|
||||||
|
/// time (driven by the notifier) instead of capturing a stale build-time
|
||||||
|
/// snapshot — that snapshot bug made ink appear only on pointer-up.
|
||||||
|
class _LiveStrokeData {
|
||||||
|
const _LiveStrokeData(this.page, this.stroke);
|
||||||
|
|
||||||
|
final int page;
|
||||||
|
final PenStroke stroke;
|
||||||
|
}
|
||||||
|
|
||||||
/// Paints one page's overlay: text highlights (under), committed ink, then the
|
/// Paints one page's overlay: text highlights (under), committed ink, then the
|
||||||
/// live in-progress stroke (over). Strokes are in normalized page coords; the
|
/// live in-progress stroke (over). Strokes are in normalized page coords; the
|
||||||
/// painter scales them to the on-screen page rect ([pageSize]) so they stay
|
/// painter scales them to the on-screen page rect ([pageSize]) so they stay
|
||||||
/// glued to the page under pdfrx's native zoom/scroll.
|
/// glued to the page under pdfrx's native zoom/scroll.
|
||||||
|
///
|
||||||
|
/// The live stroke is read from [liveStrokeVN] at paint time (not passed as a
|
||||||
|
/// constructor snapshot) so that mid-stroke updates — which notify the merged
|
||||||
|
/// repaint Listenable — redraw the in-progress ink immediately. Committed
|
||||||
|
/// strokes + highlights still arrive via the constructor (they change only on
|
||||||
|
/// setState, which rebuilds this painter).
|
||||||
class _PageOverlayPainter extends CustomPainter {
|
class _PageOverlayPainter extends CustomPainter {
|
||||||
_PageOverlayPainter({
|
_PageOverlayPainter({
|
||||||
required Listenable repaint,
|
required Listenable overlayRepaint,
|
||||||
|
required this.liveStrokeVN,
|
||||||
|
required this.pageIndex,
|
||||||
required this.strokes,
|
required this.strokes,
|
||||||
required this.highlights,
|
required this.highlights,
|
||||||
required this.liveStroke,
|
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
required this.thinning,
|
required this.thinning,
|
||||||
}) : super(repaint: repaint);
|
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
|
||||||
|
|
||||||
|
/// Live stroke source, read at paint time. Only painted when its page matches
|
||||||
|
/// [pageIndex].
|
||||||
|
final ValueListenable<_LiveStrokeData?> liveStrokeVN;
|
||||||
|
final int pageIndex;
|
||||||
final List<PenStroke> strokes;
|
final List<PenStroke> strokes;
|
||||||
final List<Rect> highlights;
|
final List<Rect> highlights;
|
||||||
final PenStroke? liveStroke;
|
|
||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
final double thinning;
|
final double thinning;
|
||||||
|
|
||||||
@@ -1205,16 +1412,16 @@ class _PageOverlayPainter extends CustomPainter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Live stroke.
|
// 3. Live stroke — read from the notifier at paint time, only for this page.
|
||||||
final live = liveStroke;
|
final live = liveStrokeVN.value;
|
||||||
if (live != null && live.points.isNotEmpty) {
|
if (live != null && live.page == pageIndex && live.stroke.points.isNotEmpty) {
|
||||||
final path =
|
final path = buildStrokePath(live.stroke, size,
|
||||||
buildStrokePath(live, size, isComplete: false, thinning: thinning);
|
isComplete: false, thinning: thinning);
|
||||||
if (!path.getBounds().isEmpty) {
|
if (!path.getBounds().isEmpty) {
|
||||||
canvas.drawPath(
|
canvas.drawPath(
|
||||||
path,
|
path,
|
||||||
Paint()
|
Paint()
|
||||||
..color = Color(live.color)
|
..color = Color(live.stroke.color)
|
||||||
..style = PaintingStyle.fill
|
..style = PaintingStyle.fill
|
||||||
..isAntiAlias = true,
|
..isAntiAlias = true,
|
||||||
);
|
);
|
||||||
@@ -1224,15 +1431,55 @@ class _PageOverlayPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(_PageOverlayPainter old) =>
|
bool shouldRepaint(_PageOverlayPainter old) =>
|
||||||
|
!identical(old.liveStrokeVN, liveStrokeVN) ||
|
||||||
|
old.pageIndex != pageIndex ||
|
||||||
!identical(old.strokes, strokes) ||
|
!identical(old.strokes, strokes) ||
|
||||||
old.strokes.length != strokes.length ||
|
old.strokes.length != strokes.length ||
|
||||||
!identical(old.highlights, highlights) ||
|
!identical(old.highlights, highlights) ||
|
||||||
old.highlights.length != highlights.length ||
|
old.highlights.length != highlights.length ||
|
||||||
!identical(old.liveStroke, liveStroke) ||
|
|
||||||
old.pageSize != pageSize ||
|
old.pageSize != pageSize ||
|
||||||
old.thinning != thinning;
|
old.thinning != thinning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only handle on the live-stroke painter wiring (Bug 1 regression guard).
|
||||||
|
///
|
||||||
|
/// The live stroke must be read from a [ValueListenable] at PAINT time, so a
|
||||||
|
/// mid-stroke update repaints WITHOUT a rebuild of `pageOverlaysBuilder`. This
|
||||||
|
/// seam lets a widget test pump the real painter and assert that pushing a new
|
||||||
|
/// value into the notifier triggers a repaint (which the old build-time-snapshot
|
||||||
|
/// design did not).
|
||||||
|
@visibleForTesting
|
||||||
|
class LiveStrokeOverlayHarness {
|
||||||
|
LiveStrokeOverlayHarness({required this.pageIndex});
|
||||||
|
|
||||||
|
final int pageIndex;
|
||||||
|
final ValueNotifier<int> overlayRepaint = ValueNotifier<int>(0);
|
||||||
|
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
|
||||||
|
|
||||||
|
/// Push a live stroke for [page] (or null to clear). Mirrors what
|
||||||
|
/// `_updateLiveStroke`/`_endStroke` do at runtime.
|
||||||
|
void setLiveStroke(int page, PenStroke? stroke) {
|
||||||
|
_liveStrokeVN.value = stroke == null ? null : _LiveStrokeData(page, stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real [CustomPainter] used by the editor, wired to this harness's
|
||||||
|
/// notifiers exactly as `pageOverlaysBuilder` wires it.
|
||||||
|
CustomPainter buildPainter() => _PageOverlayPainter(
|
||||||
|
overlayRepaint: overlayRepaint,
|
||||||
|
liveStrokeVN: _liveStrokeVN,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
strokes: const [],
|
||||||
|
highlights: const [],
|
||||||
|
pageSize: const Size(100, 100),
|
||||||
|
thinning: kDefaultPenThinning,
|
||||||
|
);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
overlayRepaint.dispose();
|
||||||
|
_liveStrokeVN.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
|
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
|
||||||
/// Tap opens the anchor's split view; long-press deletes the anchor.
|
/// Tap opens the anchor's split view; long-press deletes the anchor.
|
||||||
class _ScratchLinkMarker extends StatelessWidget {
|
class _ScratchLinkMarker extends StatelessWidget {
|
||||||
|
|||||||
126
test/pen_editor_live_stroke_test.dart
Normal file
126
test/pen_editor_live_stroke_test.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Regression guard for Bug 1 ("字迹写完才出现"): the PDF editor's per-page overlay
|
||||||
|
// painter must read the in-progress stroke from a ValueListenable at PAINT time,
|
||||||
|
// so a mid-stroke update repaints the live ink WITHOUT re-running
|
||||||
|
// pageOverlaysBuilder (which only re-runs on setState). The old design captured
|
||||||
|
// the live stroke as a build-time snapshot, so _bumpOverlay repainted the
|
||||||
|
// painter but it still drew the stale (null) snapshot — the stroke only appeared
|
||||||
|
// on pointer-up.
|
||||||
|
//
|
||||||
|
// These tests pump the REAL _PageOverlayPainter (via LiveStrokeOverlayHarness)
|
||||||
|
// and assert (1) paint() reflects the CURRENT notifier value, and (2) updating
|
||||||
|
// the notifier alone drives a CustomPaint repaint with no widget rebuild.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/canvas/pen_editor_screen.dart';
|
||||||
|
import 'package:badnote/editor/canvas/pen_stroke.dart';
|
||||||
|
import 'package:badnote/editor/engine/brush.dart';
|
||||||
|
|
||||||
|
PenStroke _stroke() => PenStroke(
|
||||||
|
points: const [
|
||||||
|
PenPoint(0.2, 0.2, 0.8),
|
||||||
|
PenPoint(0.5, 0.5, 0.8),
|
||||||
|
PenPoint(0.8, 0.8, 0.8),
|
||||||
|
],
|
||||||
|
color: 0xFF000000,
|
||||||
|
width: 0.01,
|
||||||
|
kind: PenStrokeKind.pen,
|
||||||
|
brush: BrushKind.fountainPen,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Records the number of drawPath calls a painter issues for the given size.
|
||||||
|
int _drawPathCount(CustomPainter painter, Size size) {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = _CountingCanvas(Canvas(recorder));
|
||||||
|
painter.paint(canvas, size);
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
return canvas.drawPathCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const size = Size(100, 100);
|
||||||
|
|
||||||
|
test('paint() reads the CURRENT live stroke from the notifier at paint time',
|
||||||
|
() {
|
||||||
|
final harness = LiveStrokeOverlayHarness(pageIndex: 0);
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
final painter = harness.buildPainter();
|
||||||
|
|
||||||
|
// Idle: nothing to draw.
|
||||||
|
expect(_drawPathCount(painter, size), 0);
|
||||||
|
|
||||||
|
// Mid-stroke: pushing a live stroke into the notifier must make the SAME
|
||||||
|
// painter instance draw it on the next paint (no reconstruction).
|
||||||
|
harness.setLiveStroke(0, _stroke());
|
||||||
|
expect(_drawPathCount(painter, size), 1,
|
||||||
|
reason: 'painter must read the live stroke at paint time, not a '
|
||||||
|
'build-time snapshot');
|
||||||
|
|
||||||
|
// Cleared: back to nothing.
|
||||||
|
harness.setLiveStroke(0, null);
|
||||||
|
expect(_drawPathCount(painter, size), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a live stroke on a DIFFERENT page is not painted here', () {
|
||||||
|
final harness = LiveStrokeOverlayHarness(pageIndex: 0);
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
final painter = harness.buildPainter();
|
||||||
|
|
||||||
|
harness.setLiveStroke(3, _stroke()); // belongs to page 3, not page 0
|
||||||
|
expect(_drawPathCount(painter, size), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('updating the live-stroke notifier repaints without a rebuild',
|
||||||
|
(tester) async {
|
||||||
|
final harness = LiveStrokeOverlayHarness(pageIndex: 0);
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
|
||||||
|
var builds = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Builder(builder: (context) {
|
||||||
|
builds++;
|
||||||
|
return CustomPaint(
|
||||||
|
painter: harness.buildPainter(),
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(builds, 1);
|
||||||
|
|
||||||
|
// Pushing a live stroke must trigger a repaint of the CustomPaint via its
|
||||||
|
// merged repaint Listenable — WITHOUT rebuilding the widget tree (which is
|
||||||
|
// exactly the path _bumpOverlay/setState would NOT cover mid-stroke).
|
||||||
|
harness.setLiveStroke(0, _stroke());
|
||||||
|
await tester.pump();
|
||||||
|
expect(builds, 1, reason: 'no widget rebuild should be needed');
|
||||||
|
|
||||||
|
// And the painter now draws the live ink.
|
||||||
|
expect(_drawPathCount(harness.buildPainter(), size), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [Canvas] proxy that counts drawPath calls.
|
||||||
|
class _CountingCanvas implements Canvas {
|
||||||
|
_CountingCanvas(this._inner);
|
||||||
|
|
||||||
|
final Canvas _inner;
|
||||||
|
int drawPathCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void drawPath(ui.Path path, ui.Paint paint) {
|
||||||
|
drawPathCount++;
|
||||||
|
_inner.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void noSuchMethod(Invocation invocation) =>
|
||||||
|
_forward(invocation);
|
||||||
|
|
||||||
|
dynamic _forward(Invocation i) => null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user