Some checks failed
CI / Windows build (push) Has been cancelled
"优化橡皮擦工具,你优化在哪" — the eraser already did segment erase, but the radius was a hardcoded const with no size control and no whole-stroke mode. Add both, OneNote/Notability-style: - PenConfig: eraserRadius (0.005-0.1, default 0.02) + eraserWholeStroke bool, with copyWith / JSON / setters. - PenCanvas: uses widget.eraserRadius for the live hit area AND the cursor preview (they stay in sync); eraserWholeStroke=true removes the whole stroke on contact, false keeps the segment-split behavior. - pen_editor threads both from PenConfig. - pen-settings: new Eraser section — size slider + "Stroke Eraser" switch. Tests: pen_eraser_mode_widget proves point-eraser keeps the untouched ends while stroke-eraser deletes the whole stroke from the same pass. flutter analyze: 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
621 lines
24 KiB
Dart
621 lines
24 KiB
Dart
// lib/editor/canvas/pen_canvas.dart
|
|
//
|
|
// Pen-first canvas: ONE shared transform (an InteractiveViewer driven by a
|
|
// TransformationController we own) zooms/pans BOTH the PDF page bitmap and the
|
|
// ink layer together. A Listener wrapped around the InteractiveViewer reads raw
|
|
// pointer kind + pressure and tracks the active pointer COUNT to arbitrate
|
|
// draw vs pan/zoom — we own the gesture pipeline, pdfrx never sees gestures.
|
|
//
|
|
// Gesture arbitration (reimplemented clean-room from Saber's documented model):
|
|
// - A draw gesture is exactly ONE active pointer that is a stylus / inverted
|
|
// stylus, OR (when the user's finger-drawing toggle is on) a single finger.
|
|
// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. If a 2nd
|
|
// pointer lands while a stroke is in progress, that stroke is discarded
|
|
// (accidental palm/finger).
|
|
// - Palm rejection: once any stylus event is seen in a session, finger-drawing
|
|
// is forced OFF so a resting palm/finger pans instead of marking.
|
|
// - While a stroke is active, the InteractiveViewer's pan is disabled so it
|
|
// can't fight the stroke; pinch-zoom still works because a 2nd pointer
|
|
// cancels the stroke first, re-enabling pan/zoom.
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../engine/stroke_eraser.dart';
|
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
|
import '../engine/stroke_model.dart';
|
|
import '../engine/stroke_store.dart';
|
|
import '../input/input_arbiter.dart' as arbiter;
|
|
import '../input/pen_config.dart';
|
|
import '../input/pressure_curve.dart';
|
|
import '../input/pen_input_service.dart';
|
|
import '../render/ink_picture_cache.dart';
|
|
import '../render/live_ink_painter.dart' as render;
|
|
import '../render/static_ink_painter.dart' as render;
|
|
import 'ink_painters.dart' show EraserPreviewPainter;
|
|
import 'pen_interactive_viewer.dart';
|
|
import 'pen_stroke.dart';
|
|
|
|
/// The active tool on the pen canvas.
|
|
enum CanvasTool { pen, highlighter, eraser }
|
|
|
|
class PenCanvas extends StatefulWidget {
|
|
const PenCanvas({
|
|
super.key,
|
|
required this.pageWidget,
|
|
required this.pageSize,
|
|
required this.strokes,
|
|
required this.transformationController,
|
|
required this.tool,
|
|
required this.color,
|
|
required this.strokeWidth,
|
|
required this.onStrokeComplete,
|
|
required this.onEraseStroke,
|
|
this.allowFingerDrawing = false,
|
|
this.minScale = 0.5,
|
|
this.maxScale = 8.0,
|
|
this.onPenDebug,
|
|
this.thinning = kDefaultPenThinning,
|
|
this.pressureGamma = kNaturalPressureGamma,
|
|
this.pressureFloor = kNaturalPressureFloor,
|
|
this.eraserRadius = kDefaultEraserRadius,
|
|
this.eraserWholeStroke = false,
|
|
this.sideButtonAction = PenButtonAction.eraser,
|
|
this.eraserEndAction = PenButtonAction.eraser,
|
|
this.onPenButtonAction,
|
|
});
|
|
|
|
/// Debug hook: called with a readout of the latest pen event
|
|
/// (kind / pressure / pressureMin / pressureMax) so we can see what Windows
|
|
/// actually delivers. Null in release UI.
|
|
final void Function(String readout)? onPenDebug;
|
|
|
|
/// The rendered PDF page bitmap, already sized to [pageSize].
|
|
final Widget pageWidget;
|
|
|
|
/// On-screen size (at scale 1.0) of the page rectangle in logical pixels.
|
|
/// Ink normalized coords map onto this rectangle.
|
|
final Size pageSize;
|
|
|
|
/// Committed strokes for the CURRENT page (normalized coords).
|
|
final List<PenStroke> strokes;
|
|
|
|
/// Shared transform driving both page and ink.
|
|
final TransformationController transformationController;
|
|
|
|
final CanvasTool tool;
|
|
final Color color;
|
|
|
|
/// Pen width as a fraction of page width (so it zooms with the page).
|
|
final double strokeWidth;
|
|
|
|
/// Called with a finished stroke (normalized coords) to commit it.
|
|
final void Function(PenStroke stroke) onStrokeComplete;
|
|
|
|
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
|
|
/// after a partial (segment) erase. An empty [replacements] list removes the
|
|
/// stroke entirely (whole-stroke erase).
|
|
final void Function(int strokeIndex, List<PenStroke> replacements)
|
|
onEraseStroke;
|
|
|
|
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
|
/// seen (palm rejection).
|
|
final bool allowFingerDrawing;
|
|
|
|
final double minScale;
|
|
final double maxScale;
|
|
|
|
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
|
final double thinning;
|
|
|
|
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
|
|
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
|
|
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
|
|
final double pressureGamma;
|
|
|
|
/// Minimum shaped pressure, so a light stroke still has body instead of
|
|
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
|
|
final double pressureFloor;
|
|
|
|
/// Eraser radius as a fraction of page width (live hit area + cursor size).
|
|
/// From `PenConfig.eraserRadius`.
|
|
final double eraserRadius;
|
|
|
|
/// When true the eraser removes a whole stroke on contact (OneNote-style);
|
|
/// when false it does a partial / segment erase. From
|
|
/// `PenConfig.eraserWholeStroke`.
|
|
final bool eraserWholeStroke;
|
|
|
|
/// Configured action for the pen's side barrel button (W3 — resolved against
|
|
/// the native pen plugin's flags on Windows).
|
|
final PenButtonAction sideButtonAction;
|
|
|
|
/// Configured action for the pen's eraser/inverted end (W3).
|
|
final PenButtonAction eraserEndAction;
|
|
|
|
/// Fired (edge-triggered) when a hardware pen button mapped to a non-eraser
|
|
/// action (undo / toggleTool) is pressed.
|
|
final void Function(PenButtonAction action)? onPenButtonAction;
|
|
|
|
@override
|
|
State<PenCanvas> createState() => _PenCanvasState();
|
|
}
|
|
|
|
class _PenCanvasState extends State<PenCanvas> {
|
|
/// Active (down) pointers by id → their device kind. Size == pointer count.
|
|
final Map<int, PointerDeviceKind> _activePointers = {};
|
|
|
|
/// The pointer id currently driving a stroke, or null.
|
|
int? _drawPointer;
|
|
|
|
/// In-progress stroke points (normalized).
|
|
final List<PenPoint> _livePoints = [];
|
|
|
|
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
|
|
PenStroke? _liveStroke;
|
|
|
|
/// True when the active stylus reports the eraser signal (barrel button or
|
|
/// inverted stylus), detected on hover/down.
|
|
bool _eraserActive = false;
|
|
|
|
/// Eraser preview cursor (normalized page coords), or null when not in eraser
|
|
/// mode / the pen is not near the page. A ValueNotifier so the preview layer
|
|
/// repaints on cursor moves WITHOUT rebuilding the whole canvas every frame
|
|
/// (the old per-move setState was the eraser-lag source).
|
|
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);
|
|
|
|
/// Committed ink mirrored as the canonical [EditorStroke] model, driving the
|
|
/// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
|
|
/// The cache replays a recorded ui.Picture for the committed layer, so pinch /
|
|
/// pan / live-stroke frames never re-rasterize the committed ink — the
|
|
/// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
|
|
/// parent replaces with a fresh list identity on every commit/erase).
|
|
final StrokeStore _store = StrokeStore();
|
|
final InkPictureCache _inkCache = InkPictureCache();
|
|
List<PenStroke>? _syncedStrokesRef;
|
|
static const String _inkHostId = 'pen-canvas';
|
|
|
|
/// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
|
|
/// list (identity change ⇒ a commit/erase happened). Bumping the store
|
|
/// revision invalidates the cached Picture so the committed layer repaints.
|
|
void _syncStore() {
|
|
if (identical(_syncedStrokesRef, widget.strokes)) return;
|
|
_syncedStrokesRef = widget.strokes;
|
|
_store.replaceAll(
|
|
widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
|
|
);
|
|
}
|
|
|
|
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
|
|
/// eraser signal is live).
|
|
bool get _isEraserMode =>
|
|
widget.tool == CanvasTool.eraser || _eraserActive;
|
|
|
|
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
|
double get _pageAspect => widget.pageSize.width <= 0
|
|
? 1.0
|
|
: widget.pageSize.height / widget.pageSize.width;
|
|
|
|
// The explicit user toggle wins: if finger-drawing is ON, a single finger
|
|
// draws even after a stylus has been seen. (Palm rejection when the toggle is
|
|
// OFF is automatic — fingers simply never draw — and a 2nd pointer always
|
|
// cancels an in-progress stroke regardless.)
|
|
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
|
|
|
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
|
|
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
|
/// usable pressure range (then perfect_freehand simulates pressure).
|
|
///
|
|
/// The raw normalized force is then shaped by the pressure-response curve
|
|
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
|
|
/// — and because the shaping happens at capture, the live stroke and the PDF
|
|
/// export replay identical pressures (no divergence).
|
|
double? _normalizedPressure(PointerEvent event) {
|
|
if (!_isStylus(event.kind)) return null;
|
|
final double? raw = _rawNormalizedPressure(event);
|
|
if (raw == null) return null;
|
|
return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma)
|
|
.apply(raw);
|
|
}
|
|
|
|
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
|
|
double? _rawNormalizedPressure(PointerEvent event) {
|
|
final range = event.pressureMax - event.pressureMin;
|
|
if (range > 0.0001) {
|
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
|
}
|
|
// No advertised range (some Windows pen stacks): use the raw normalized
|
|
// pressure directly if it's a usable non-degenerate value, so we still get
|
|
// real force instead of falling back to velocity simulation.
|
|
if (event.pressure > 0.0 && event.pressure < 1.0) {
|
|
return event.pressure;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The eraser signal. Two sources, ORed:
|
|
/// 1. Flutter-native: secondary button held or an inverted stylus (works on
|
|
/// desktop / platforms that surface these).
|
|
/// 2. Windows native pen plugin: barrel / inverted / eraser flags that
|
|
/// Flutter 3.44 drops, mapped through the configured side-button /
|
|
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
|
|
/// erasing — correct for an eraser.
|
|
bool _isEraserSignal(PointerEvent event) {
|
|
// BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
|
|
// == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
|
|
// down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
|
|
// (0x02) is false — the side button registered only on hover, never while
|
|
// drawing. `& kSecondaryButton != 0` catches both.
|
|
if ((event.buttons & kSecondaryButton) != 0 ||
|
|
event.kind == PointerDeviceKind.invertedStylus) {
|
|
return true;
|
|
}
|
|
final hw = PenInputService.instance;
|
|
if (hw.isActive) {
|
|
final s = hw.current;
|
|
if ((s.inverted || s.eraser) &&
|
|
widget.eraserEndAction == PenButtonAction.eraser) {
|
|
return true;
|
|
}
|
|
if (s.barrel && widget.sideButtonAction == PenButtonAction.eraser) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Resolve the currently-active configured action from the native pen flags
|
|
/// (eraser-end takes precedence over the side button when both are set).
|
|
PenButtonAction _activeHwAction() {
|
|
final hw = PenInputService.instance;
|
|
if (!hw.isActive) return PenButtonAction.none;
|
|
final s = hw.current;
|
|
if (s.inverted || s.eraser) return widget.eraserEndAction;
|
|
if (s.barrel) return widget.sideButtonAction;
|
|
return PenButtonAction.none;
|
|
}
|
|
|
|
/// Last hardware action seen, for rising-edge detection of undo/toggleTool.
|
|
PenButtonAction _lastHwAction = PenButtonAction.none;
|
|
|
|
/// Edge-triggered dispatch of non-eraser button actions (undo / toggleTool).
|
|
/// Eraser is handled level-triggered by [_isEraserSignal]; pan suppresses
|
|
/// drawing via [_shouldDraw].
|
|
void _dispatchHwButtonActions() {
|
|
final action = _activeHwAction();
|
|
if (action == _lastHwAction) return;
|
|
_lastHwAction = action;
|
|
if (action == PenButtonAction.undo ||
|
|
action == PenButtonAction.toggleTool) {
|
|
widget.onPenButtonAction?.call(action);
|
|
}
|
|
}
|
|
|
|
/// True while a hardware button mapped to `pan` is held (suppresses drawing
|
|
/// so the InteractiveViewer pans instead).
|
|
bool get _hwPanActive => _activeHwAction() == PenButtonAction.pan;
|
|
|
|
/// Pen tilt magnitude (degrees) for a stylus event, or null when unavailable.
|
|
double? _tiltFor(PointerEvent event) {
|
|
if (!_isStylus(event.kind)) return null;
|
|
final hw = PenInputService.instance;
|
|
if (!hw.isActive) return null;
|
|
final t = hw.current.tiltMagnitude;
|
|
return t == 0 ? null : t;
|
|
}
|
|
|
|
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
|
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
|
/// the tests can never disagree on the rule.
|
|
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
|
|
activePointerCount: _activePointers.length,
|
|
kind: kind,
|
|
fingerDrawingEnabled: _fingerDrawingEnabled,
|
|
hwPanActive: _hwPanActive,
|
|
);
|
|
|
|
// --- Coordinate mapping ---------------------------------------------------
|
|
|
|
/// Map a global pointer position into normalized page coords using the
|
|
/// shared transform (inverse) and this widget's geometry.
|
|
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
|
{double? tilt}) {
|
|
final box = context.findRenderObject() as RenderBox?;
|
|
if (box == null) return null;
|
|
final local = box.globalToLocal(globalPosition);
|
|
|
|
// Undo the InteractiveViewer transform to get scene (untransformed) coords.
|
|
final scene = widget.transformationController.toScene(local);
|
|
|
|
final nx = scene.dx / widget.pageSize.width;
|
|
final ny = scene.dy / widget.pageSize.height;
|
|
return PenPoint(nx, ny, pressure, tilt: tilt);
|
|
}
|
|
|
|
// --- Stroke lifecycle -----------------------------------------------------
|
|
|
|
void _startStroke(PointerDownEvent event) {
|
|
_drawPointer = event.pointer;
|
|
_livePoints.clear();
|
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
|
tilt: _tiltFor(event));
|
|
if (p != null) _livePoints.add(p);
|
|
|
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
|
_eraserCursor.value = p;
|
|
_eraseAt(p);
|
|
// No setState here: the preview repaints via the notifier, and any erased
|
|
// stroke repaints via the editor's onEraseStroke setState. (_liveStroke is
|
|
// already null in eraser mode.)
|
|
if (_liveStroke != null) setState(() => _liveStroke = null);
|
|
return;
|
|
}
|
|
_updateLiveStroke();
|
|
}
|
|
|
|
void _extendStroke(PointerMoveEvent event) {
|
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
|
tilt: _tiltFor(event));
|
|
if (p == null) return;
|
|
|
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
|
_eraserCursor.value = p;
|
|
_eraseAt(p);
|
|
return;
|
|
}
|
|
_livePoints.add(p);
|
|
_updateLiveStroke();
|
|
}
|
|
|
|
void _endStroke() {
|
|
if (_drawPointer == null) return;
|
|
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
|
|
if (!wasEraser && _livePoints.isNotEmpty) {
|
|
widget.onStrokeComplete(
|
|
PenStroke(
|
|
points: List.of(_livePoints),
|
|
color: _currentColor().toARGB32(),
|
|
width: widget.strokeWidth,
|
|
kind: _currentKind(),
|
|
),
|
|
);
|
|
}
|
|
_drawPointer = null;
|
|
_livePoints.clear();
|
|
_eraserCursor.value = null; // hide the preview when the pen lifts
|
|
setState(() => _liveStroke = null);
|
|
}
|
|
|
|
/// Discard the in-progress stroke without committing (palm/2nd-finger).
|
|
void _cancelStroke() {
|
|
_drawPointer = null;
|
|
_livePoints.clear();
|
|
_eraserCursor.value = null;
|
|
setState(() => _liveStroke = null);
|
|
}
|
|
|
|
void _updateLiveStroke() {
|
|
setState(() {
|
|
_liveStroke = PenStroke(
|
|
points: List.of(_livePoints),
|
|
color: _currentColor().toARGB32(),
|
|
width: widget.strokeWidth,
|
|
kind: _currentKind(),
|
|
);
|
|
});
|
|
}
|
|
|
|
PenStrokeKind _currentKind() =>
|
|
widget.tool == CanvasTool.highlighter
|
|
? PenStrokeKind.highlighter
|
|
: PenStrokeKind.pen;
|
|
|
|
Color _currentColor() => widget.tool == CanvasTool.highlighter
|
|
? widget.color.withAlpha(0x80)
|
|
: widget.color;
|
|
|
|
/// Partial (segment) erase: find the first committed stroke the eraser circle
|
|
/// touches and replace it with its surviving pieces. The eraser radius is in
|
|
/// normalized page-width fractions; [aspect] corrects the y axis so the circle
|
|
/// stays round on screen (the page rect is not square).
|
|
void _eraseAt(PenPoint? p) {
|
|
if (p == null) return;
|
|
final radius = widget.eraserRadius; // normalized (page-width fraction)
|
|
final aspect = _pageAspect;
|
|
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
|
final stroke = widget.strokes[i];
|
|
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
|
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
|
|
// Point-eraser mode (default): cut out the touched span, keep the rest.
|
|
final pieces = widget.eraserWholeStroke
|
|
? const <PenStroke>[]
|
|
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
|
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
|
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
|
widget.onEraseStroke(i, pieces);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// --- Listener callbacks ---------------------------------------------------
|
|
|
|
/// Highest NORMALIZED pressure seen since the diagnostic was last reset —
|
|
/// makes "does pressure actually vary?" unambiguous on the readout.
|
|
double _peakNorm = 0;
|
|
|
|
void _emitPenDebug(PointerEvent event) {
|
|
final cb = widget.onPenDebug;
|
|
if (cb == null) return;
|
|
final norm = _normalizedPressure(event);
|
|
if (norm != null && norm > _peakNorm) _peakNorm = norm;
|
|
cb('${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
|
|
'/${event.pressureMax.toStringAsFixed(0)} '
|
|
'norm=${norm?.toStringAsFixed(3) ?? "null"} '
|
|
'peak=${_peakNorm.toStringAsFixed(3)} '
|
|
'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'
|
|
'\n${PenInputService.instance.debugSummary}');
|
|
}
|
|
|
|
void _onPointerHover(PointerHoverEvent event) {
|
|
if (_isStylus(event.kind)) {
|
|
_emitPenDebug(event);
|
|
// Fire edge-triggered button actions (undo / toggleTool) on hover so a
|
|
// mapped barrel press works without first touching down.
|
|
_dispatchHwButtonActions();
|
|
// Detect eraser (barrel button / inverted) while hovering.
|
|
_eraserActive = _isEraserSignal(event);
|
|
}
|
|
}
|
|
|
|
void _onPointerDown(PointerDownEvent event) {
|
|
if (event.kind == PointerDeviceKind.trackpad) return;
|
|
if (_isStylus(event.kind)) {
|
|
_emitPenDebug(event);
|
|
// Fire edge-triggered button actions for a direct pen-down (no prior
|
|
// hover); the native observer latched this contact's flags before Flutter
|
|
// synthesized this event (plan M1/M2).
|
|
_dispatchHwButtonActions();
|
|
}
|
|
|
|
_activePointers[event.pointer] = event.kind;
|
|
|
|
// A 2nd pointer arriving during a stroke = pinch/palm → cancel the stroke
|
|
// and let the InteractiveViewer take over pan/zoom.
|
|
if (_activePointers.length >= 2) {
|
|
if (_drawPointer != null) _cancelStroke();
|
|
return;
|
|
}
|
|
|
|
// Single pointer: decide draw vs pan. Eraser is on if this stylus down
|
|
// signals it (barrel button / inverted), or hover already flagged it.
|
|
if (_isStylus(event.kind)) {
|
|
_eraserActive = _eraserActive || _isEraserSignal(event);
|
|
} else {
|
|
_eraserActive = false;
|
|
}
|
|
|
|
if (_shouldDraw(event.kind)) {
|
|
_startStroke(event);
|
|
}
|
|
}
|
|
|
|
void _onPointerMove(PointerMoveEvent event) {
|
|
if (_isStylus(event.kind)) _emitPenDebug(event);
|
|
if (event.pointer != _drawPointer) return;
|
|
if (_activePointers.length >= 2) return; // pinch owns it
|
|
_extendStroke(event);
|
|
}
|
|
|
|
void _onPointerUp(PointerUpEvent event) {
|
|
final wasDrawer = event.pointer == _drawPointer;
|
|
_activePointers.remove(event.pointer);
|
|
if (wasDrawer) _endStroke();
|
|
}
|
|
|
|
void _onPointerCancel(PointerCancelEvent event) {
|
|
final wasDrawer = event.pointer == _drawPointer;
|
|
_activePointers.remove(event.pointer);
|
|
if (wasDrawer) _cancelStroke();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_eraserCursor.dispose();
|
|
_inkCache.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The PEN never reaches PenInteractiveViewer's recognizer (it excludes
|
|
// stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
|
|
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
|
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
|
// so a pinch re-enables pan/zoom immediately.
|
|
final panEnabled = _drawPointer == null;
|
|
|
|
// Mirror committed strokes into the revision-tracked store (only re-mirrors
|
|
// when the parent handed us a new list identity).
|
|
_syncStore();
|
|
final liveEditorStroke =
|
|
_liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);
|
|
|
|
return Listener(
|
|
onPointerHover: _onPointerHover,
|
|
onPointerDown: _onPointerDown,
|
|
onPointerMove: _onPointerMove,
|
|
onPointerUp: _onPointerUp,
|
|
onPointerCancel: _onPointerCancel,
|
|
child: PenInteractiveViewer(
|
|
transformationController: widget.transformationController,
|
|
minScale: widget.minScale,
|
|
maxScale: widget.maxScale,
|
|
panEnabled: panEnabled,
|
|
scaleEnabled: true,
|
|
child: SizedBox(
|
|
width: widget.pageSize.width,
|
|
height: widget.pageSize.height,
|
|
child: Stack(
|
|
children: [
|
|
// PDF page bitmap. Wrapped in its own RepaintBoundary (W2) so the
|
|
// per-move live-ink repaints and the static-ink repaints never
|
|
// mark the page's raster layer dirty — isolating it from
|
|
// ink-driven repaints. (The definitive crisp-on-zoom / no-flash
|
|
// fix is the P0.5 page_tile DPI-on-settle double-buffer; this
|
|
// boundary is the safe, non-regressive interim per plan M3.)
|
|
Positioned.fill(
|
|
child: RepaintBoundary(child: widget.pageWidget),
|
|
),
|
|
// Committed ink (static layer, isolated repaint). Backed by the
|
|
// revision-gated ui.Picture cache (P0 step 3): unchanged across
|
|
// pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
|
|
Positioned.fill(
|
|
child: RepaintBoundary(
|
|
child: CustomPaint(
|
|
painter: render.StaticInkPainter(
|
|
hostId: _inkHostId,
|
|
store: _store,
|
|
pageSize: widget.pageSize,
|
|
cache: _inkCache,
|
|
thinning: widget.thinning,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Eraser preview: faint outline on strokes about to be deleted +
|
|
// the eraser circle. Mounted only in eraser mode with a cursor.
|
|
if (_isEraserMode)
|
|
Positioned.fill(
|
|
child: RepaintBoundary(
|
|
child: CustomPaint(
|
|
painter: EraserPreviewPainter(
|
|
strokes: widget.strokes,
|
|
cursor: _eraserCursor,
|
|
radius: widget.eraserRadius,
|
|
aspect: _pageAspect,
|
|
pageSize: widget.pageSize,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Live ink (current stroke only, isolated repaint).
|
|
Positioned.fill(
|
|
child: RepaintBoundary(
|
|
child: CustomPaint(
|
|
painter: render.LiveInkPainter(
|
|
live: liveEditorStroke,
|
|
pageSize: widget.pageSize,
|
|
thinning: widget.thinning,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|