// 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 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); } }