Files
BadNote/lib/editor/pdf/pen_capture_region.dart

199 lines
8.3 KiB
Dart
Raw Normal View History

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