// 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 'ink_painters.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, }); /// 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 with the index of a committed stroke to erase (stroke-erase). final void Function(int strokeIndex) 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; @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 once any stylus event is seen this session → finger-drawing forced /// off so a resting palm pans instead of marking. bool _stylusSeen = false; /// True when the active stylus reports the eraser signal (barrel button or /// inverted stylus), detected on hover/down. bool _eraserActive = false; bool get _fingerDrawingEnabled => widget.allowFingerDrawing && !_stylusSeen; bool _isStylus(PointerDeviceKind kind) => kind == PointerDeviceKind.stylus || kind == PointerDeviceKind.invertedStylus; /// Normalize stylus pressure to [0,1], or null when the device reports no /// usable pressure range (then perfect_freehand simulates pressure). double? _normalizedPressure(PointerEvent event) { if (!_isStylus(event.kind)) return null; if (event.pressureMin == event.pressureMax) return null; final range = event.pressureMax - event.pressureMin; return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); } /// The eraser signal: barrel/secondary button held, or an inverted stylus. bool _isEraserSignal(PointerEvent event) => event.buttons == kSecondaryButton || event.kind == PointerDeviceKind.invertedStylus; /// Decide whether the gesture currently forming should DRAW. /// True iff exactly one active pointer AND (stylus OR finger-drawing on). bool _shouldDraw(PointerDeviceKind kind) { if (_activePointers.length != 1) return false; if (_isStylus(kind)) return true; if (kind == PointerDeviceKind.mouse) return true; if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled; return false; } // --- 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) { 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); } // --- Stroke lifecycle ----------------------------------------------------- void _startStroke(PointerDownEvent event) { _drawPointer = event.pointer; _livePoints.clear(); final p = _toNormalized(event.position, _normalizedPressure(event)); if (p != null) _livePoints.add(p); if (_eraserActive || widget.tool == CanvasTool.eraser) { _eraseAt(p); // Keep the stroke pointer reserved so moves keep erasing, but don't paint. setState(() => _liveStroke = null); return; } _updateLiveStroke(); } void _extendStroke(PointerMoveEvent event) { final p = _toNormalized(event.position, _normalizedPressure(event)); if (p == null) return; if (_eraserActive || widget.tool == CanvasTool.eraser) { _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(); setState(() => _liveStroke = null); } /// Discard the in-progress stroke without committing (palm/2nd-finger). void _cancelStroke() { _drawPointer = null; _livePoints.clear(); 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; /// Stroke-erase: remove the first committed stroke within proximity of [p]. void _eraseAt(PenPoint? p) { if (p == null) return; final radius = widget.strokeWidth * 2; // normalized radius for (var i = widget.strokes.length - 1; i >= 0; i--) { final stroke = widget.strokes[i]; for (final sp in stroke.points) { final dx = sp.x - p.x; final dy = sp.y - p.y; if (dx * dx + dy * dy < radius * radius) { widget.onEraseStroke(i); return; } } } } // --- Listener callbacks --------------------------------------------------- void _onPointerHover(PointerHoverEvent event) { if (_isStylus(event.kind)) { _stylusSeen = true; // Detect eraser (barrel button / inverted) while hovering. _eraserActive = _isEraserSignal(event); } } void _onPointerDown(PointerDownEvent event) { if (event.kind == PointerDeviceKind.trackpad) return; if (_isStylus(event.kind)) _stylusSeen = true; _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 (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 Widget build(BuildContext context) { // Pan only when NOT mid-stroke; while drawing we suppress IV pan so it // can't fight the stroke. (A 2nd finger cancels the stroke first, so pinch // re-enables pan/zoom immediately.) final panEnabled = _drawPointer == null; return Listener( onPointerHover: _onPointerHover, onPointerDown: _onPointerDown, onPointerMove: _onPointerMove, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, child: InteractiveViewer( transformationController: widget.transformationController, minScale: widget.minScale, maxScale: widget.maxScale, panEnabled: panEnabled, scaleEnabled: true, constrained: false, boundaryMargin: const EdgeInsets.all(double.infinity), child: SizedBox( width: widget.pageSize.width, height: widget.pageSize.height, child: Stack( children: [ // PDF page bitmap. Positioned.fill(child: widget.pageWidget), // Committed ink (static layer, isolated repaint). Positioned.fill( child: RepaintBoundary( child: CustomPaint( painter: StaticInkPainter( strokes: widget.strokes, pageSize: widget.pageSize, ), ), ), ), // Live ink (current stroke only, isolated repaint). Positioned.fill( child: RepaintBoundary( child: CustomPaint( painter: LiveInkPainter( stroke: _liveStroke, pageSize: widget.pageSize, ), ), ), ), ], ), ), ), ); } }