2026-06-21 22:04:49 +08:00
|
|
|
// 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<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 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<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;
|
|
|
|
|
|
2026-06-21 22:33:18 +08:00
|
|
|
// 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;
|
2026-06-21 22:04:49 +08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
final range = event.pressureMax - event.pressureMin;
|
2026-06-21 22:33:18 +08:00
|
|
|
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;
|
2026-06-21 22:04:49 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)) {
|
|
|
|
|
// Detect eraser (barrel button / inverted) while hovering.
|
|
|
|
|
_eraserActive = _isEraserSignal(event);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _onPointerDown(PointerDownEvent event) {
|
|
|
|
|
if (event.kind == PointerDeviceKind.trackpad) return;
|
|
|
|
|
|
|
|
|
|
_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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|