fix(pdf): live ink follows pen + stop zoom jump
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:
2026-06-24 20:18:22 +08:00
parent 9bb5c483d6
commit fd102b5703
2 changed files with 396 additions and 23 deletions

View 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;
}