feat(editor): M1 pdfrx spike + pen/touch capture
Some checks failed
CI / Windows build (push) Has been cancelled
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).
This commit is contained in:
198
lib/editor/pdf/pen_capture_region.dart
Normal file
198
lib/editor/pdf/pen_capture_region.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user