Some checks failed
CI / Windows build (push) Has been cancelled
Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom. Spike pane hosts PdfViewer with page-overlay ink at normalized coords + frame-time HUD; reachable from home screen for on-device testing. Bench/coordinate harness under integration_test. Generated assets are gitignored (regenerate via tool/gen_*.dart).
199 lines
8.3 KiB
Dart
199 lines
8.3 KiB
Dart
// lib/editor/pdf/pen_capture_region.dart
|
|
//
|
|
// Per-pointer-kind input transport for the BadNote PDF editor (plan §3.5).
|
|
//
|
|
// PROBLEM
|
|
// -------
|
|
// pdfrx's PdfViewer uses an internal InteractiveViewer-style pan/zoom driven by
|
|
// a GestureDetector (onScaleStart/Update/End). A widget layered on top of the
|
|
// viewer that wants to *draw* with the pen would normally compete with that
|
|
// recognizer in Flutter's **gesture arena** — and the scale recognizer is
|
|
// greedy, so a freehand pen drag would frequently be claimed by the viewer
|
|
// (pen → pan) or starve touch scrolling. pdfrx's own
|
|
// `PdfOverlayInteractionRegion` is tap-oriented only and gives us no freehand
|
|
// drag stream that bypasses the arena.
|
|
//
|
|
// SOLUTION (arena-bypass, per-kind hit-test split)
|
|
// ------------------------------------------------
|
|
// We do NOT use a GestureDetector for pen capture. Instead we install a custom
|
|
// RenderProxyBox (`_RenderPenCapture`) whose hit-test answer is conditioned on
|
|
// the pointer device kind of the pointer-DOWN currently being routed:
|
|
//
|
|
// * stylus / invertedStylus -> hitTest returns TRUE -> this box becomes the
|
|
// pointer's hit-test target and receives the
|
|
// entire down/move/up stream directly via
|
|
// RenderObject.handleEvent, NOT through the
|
|
// gesture arena (we never add a recognizer).
|
|
//
|
|
// * touch / mouse / trackpad -> hitTest returns FALSE -> the hit-test
|
|
// continues past us to the pdfrx viewer
|
|
// underneath, so single-finger touch scroll and
|
|
// pinch-zoom reach pdfrx untouched.
|
|
//
|
|
// WHY A CUSTOM BINDING IS REQUIRED (the only correct seam)
|
|
// --------------------------------------------------------
|
|
// Flutter only hit-tests a pointer on its PointerDownEvent, then CACHES the
|
|
// resulting HitTestResult and reuses it for every subsequent move/up
|
|
// (GestureBinding._handlePointerEventImmediately). Crucially,
|
|
// `hitTestInView(result, position, viewId)` is passed only the *position*, NOT
|
|
// the event — so a RenderBox.hitTest cannot read the pointer's kind from its
|
|
// arguments or from the HitTestResult. `pointerRouter.addGlobalRoute` fires
|
|
// during dispatchEvent, which is AFTER hit-test, so it is too late to influence
|
|
// the DOWN routing.
|
|
//
|
|
// The single reliable, fully-public seam is `GestureBinding.handlePointerEvent`
|
|
// (virtual), which is called with the live event immediately before the
|
|
// synchronous hit-test of that same event. [PenCaptureBinding] overrides it to
|
|
// stash the in-flight pointer kind into [PenCaptureBinding.currentPointerKind]
|
|
// before delegating to super; `_RenderPenCapture.hitTest` reads that field. The
|
|
// app must install [PenCaptureBinding] in main() (see spike_main.dart). If a
|
|
// non-PenCapture binding is in use, [currentPointerKind] stays null and the
|
|
// region defaults to NOT capturing — so touch scrolling is never accidentally
|
|
// stolen.
|
|
//
|
|
// The `captureEnabled` flag gates capture by editor mode (plan §6.1: Browse
|
|
// never captures pen; Draw/Type do). When disabled the box is hit-test
|
|
// transparent for all kinds.
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
/// Pointer kinds this region captures (plan §3.5 / §6.2: pen only; mouse is
|
|
/// never captured for drawing — pen required).
|
|
const Set<PointerDeviceKind> kPenCaptureKinds = {
|
|
PointerDeviceKind.stylus,
|
|
PointerDeviceKind.invertedStylus,
|
|
};
|
|
|
|
/// A [WidgetsFlutterBinding] that records the device kind of the pointer event
|
|
/// currently being routed, so that kind-gated hit-testing
|
|
/// ([PenCaptureRegion]) can consult it during the synchronous DOWN hit-test.
|
|
///
|
|
/// Install in main():
|
|
/// ```dart
|
|
/// void main() {
|
|
/// PenCaptureBinding.ensureInitialized();
|
|
/// pdfrxFlutterInitialize();
|
|
/// runApp(const SpikeApp());
|
|
/// }
|
|
/// ```
|
|
class PenCaptureBinding extends WidgetsFlutterBinding {
|
|
/// The device kind of the pointer event currently being handled by
|
|
/// [handlePointerEvent], valid for the duration of the synchronous hit-test
|
|
/// the framework performs for that event. Null when no binding override is
|
|
/// active or between events.
|
|
static PointerDeviceKind? currentPointerKind;
|
|
|
|
/// Ensures a [PenCaptureBinding] is the active binding and returns it.
|
|
static WidgetsBinding ensureInitialized() {
|
|
PenCaptureBinding();
|
|
return WidgetsBinding.instance;
|
|
}
|
|
|
|
@override
|
|
void handlePointerEvent(PointerEvent event) {
|
|
// Set the kind BEFORE super performs the synchronous hit-test for a DOWN.
|
|
currentPointerKind = event.kind;
|
|
try {
|
|
super.handlePointerEvent(event);
|
|
} finally {
|
|
// Leave currentPointerKind set to the last event's kind; it is only read
|
|
// transiently during hit-test (which happens inside super for DOWNs).
|
|
// We do not null it here because moves reuse the cached hit path and never
|
|
// re-hit-test, so staleness between events is harmless.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A transparent capture region that forwards the full pointer stream for
|
|
/// stylus/invertedStylus pointers to [onPenEvent] while letting touch and mouse
|
|
/// pointers fall through to whatever is painted underneath (typically a pdfrx
|
|
/// `PdfViewer`).
|
|
///
|
|
/// See the file header for the arena-bypass rationale and binding requirement.
|
|
class PenCaptureRegion extends SingleChildRenderObjectWidget {
|
|
const PenCaptureRegion({
|
|
super.key,
|
|
required this.onPenEvent,
|
|
this.captureEnabled = true,
|
|
required Widget child,
|
|
}) : super(child: child);
|
|
|
|
/// Called with every [PointerEvent] (down/move/up/cancel) of a captured pen
|
|
/// pointer, delivered raw and in order without arena arbitration.
|
|
final void Function(PointerEvent event) onPenEvent;
|
|
|
|
/// When false the region is hit-test transparent for all pointer kinds, so
|
|
/// even pen events fall through to the viewer underneath (e.g. Browse mode).
|
|
final bool captureEnabled;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) {
|
|
return _RenderPenCapture(
|
|
onPenEvent: onPenEvent,
|
|
captureEnabled: captureEnabled,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
|
(renderObject as _RenderPenCapture)
|
|
..onPenEvent = onPenEvent
|
|
..captureEnabled = captureEnabled;
|
|
}
|
|
}
|
|
|
|
/// RenderProxyBox that conditionally participates in hit-testing based on the
|
|
/// in-flight pointer's device kind (via [PenCaptureBinding.currentPointerKind])
|
|
/// and forwards captured pen events.
|
|
class _RenderPenCapture extends RenderProxyBox {
|
|
_RenderPenCapture({
|
|
required void Function(PointerEvent) onPenEvent,
|
|
required bool captureEnabled,
|
|
}) : _onPenEvent = onPenEvent,
|
|
_captureEnabled = captureEnabled;
|
|
|
|
void Function(PointerEvent) _onPenEvent;
|
|
set onPenEvent(void Function(PointerEvent) value) => _onPenEvent = value;
|
|
|
|
bool _captureEnabled;
|
|
set captureEnabled(bool value) => _captureEnabled = value;
|
|
|
|
/// Returns true only for pen kinds while capture is enabled. Returning false
|
|
/// continues the hit-test to the pdfrx viewer underneath, which is how
|
|
/// touch/mouse reach it for scroll/zoom.
|
|
///
|
|
/// We override [hitTest] directly (rather than relying on
|
|
/// [hitTestChildren]+[hitTestSelf]) so the kind check is the single decision
|
|
/// point and covers the entire box area, regardless of the translucent paint
|
|
/// child.
|
|
@override
|
|
bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
|
if (!_captureEnabled) return false;
|
|
if (!_shouldCaptureCurrentPointer()) return false;
|
|
if (!size.contains(position)) return false;
|
|
result.add(BoxHitTestEntry(this, position));
|
|
return true;
|
|
}
|
|
|
|
/// Mirrors the predicate for any caller routing through the default
|
|
/// RenderBox.hitTest dispatch (hitTestChildren then hitTestSelf).
|
|
@override
|
|
bool hitTestSelf(Offset position) =>
|
|
_captureEnabled && _shouldCaptureCurrentPointer();
|
|
|
|
bool _shouldCaptureCurrentPointer() {
|
|
final kind = PenCaptureBinding.currentPointerKind;
|
|
if (kind == null) return false; // No kind-aware binding → never steal touch.
|
|
return kPenCaptureKinds.contains(kind);
|
|
}
|
|
|
|
@override
|
|
void handleEvent(PointerEvent event, HitTestEntry entry) {
|
|
if (!_captureEnabled) return;
|
|
if (!kPenCaptureKinds.contains(event.kind)) return;
|
|
_onPenEvent(event);
|
|
}
|
|
}
|