// 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.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 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 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; /// 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 createState() => _PenCanvasState(); } class _PenCanvasState extends State { /// Active (down) pointers by id → their device kind. Size == pointer count. final Map _activePointers = {}; /// The pointer id currently driving a stroke, or null. int? _drawPointer; /// In-progress stroke points (normalized). final List _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 _eraserCursor = ValueNotifier(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? _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; /// Eraser radius as a fraction of page width (shared by the live erase and the /// preview overlay so they always agree). A decisive fixed size — the old /// strokeWidth*2 was so small that a pass removed only a couple of points and /// the stroke visibly survived ("选中了的笔画也不见得能删掉"). static const double _eraserRadius = 0.02; /// 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 = _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; final pieces = 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: _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, ), ), ), ), ], ), ), ), ); } }