/// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。 /// /// 核心设计决策: /// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟) /// - 支持触控笔模式下的掌心抑制(palm rejection) /// - 轻量去抖:±1px 移动平均过滤,消除手指微小抖动 /// - [RepaintBoundary] 隔离重绘范围,避免影响父组件 /// /// 性能目标:p99 延迟 < 16ms(满足 60fps 要求)。 library; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import 'stroke_model.dart'; import 'stroke_renderer.dart'; // ============================================================ // 手写 Canvas 组件 // ============================================================ /// 手写画布,接收用户输入并实时渲染笔画。 /// /// 使用方式: /// ```dart /// HandwritingCanvas( /// brushType: BrushType.pen, /// brushColor: '#2D2420', /// brushWidth: 3.0, /// strokes: editorState.strokes, /// onStrokeCompleted: (stroke) => context.read().add( /// StrokeCompleted(stroke), /// ), /// ) /// ``` class HandwritingCanvas extends StatefulWidget { const HandwritingCanvas({ super.key, this.brushType = BrushType.pen, this.brushColor = '#2D2420', this.brushWidth = 3.0, this.onStrokeCompleted, this.strokes = const [], }); /// 当前选中的画笔类型。 final BrushType brushType; /// 当前画笔颜色(CSS 十六进制格式,如 '#2D2420')。 final String brushColor; /// 当前画笔宽度(基准 3.0)。 final double brushWidth; /// 笔画完成时的回调,将新笔画传递给上层(通常是 BLoC)。 final ValueChanged? onStrokeCompleted; /// 已有的笔画列表(用于重绘历史内容)。 final List strokes; @override State createState() => _HandwritingCanvasState(); } class _HandwritingCanvasState extends State { // ============================================================ // 状态 // ============================================================ /// 正在绘制中的笔画采样点。 List _currentPoints = const []; /// RepaintBoundary 的 key,用于隔离重绘。 final GlobalKey _repaintBoundaryKey = GlobalKey(); /// UUID 生成器实例。 static const _uuid = Uuid(); // ============================================================ // 掌心抑制 // ============================================================ /// 是否启用掌心抑制。 /// /// 当设备支持触控笔 (stylus) 时启用:只响应 stylus 输入, /// 忽略触摸 (touch) 输入,防止手掌误触。 bool _palmRejectionEnabled = false; // ============================================================ // 去抖 // ============================================================ /// 上一个有效采样点的坐标(用于去抖计算)。 Offset? _lastAcceptedPoint; /// 去抖阈值(像素):移动距离小于此值的点将被过滤。 static const double _debounceThreshold = 1.0; // ============================================================ // 指针事件处理 // ============================================================ /// 指针按下:开始新笔画。 /// /// 掌心抑制逻辑:如果已检测到触控笔设备,则只响应 stylus 事件, /// 忽略 touch 事件。首次检测到 stylus 时自动启用抑制。 void _onPointerDown(PointerDownEvent event) { // 检测触控笔设备并启用掌心抑制 if (event.kind == PointerDeviceKind.stylus) { _palmRejectionEnabled = true; } // 掌心抑制:如果已启用且当前非 stylus 输入,忽略 if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) { return; } // 开始新笔画 final point = StrokePoint( x: event.localPosition.dx, y: event.localPosition.dy, pressure: event.pressure, timestamp: DateTime.now().millisecondsSinceEpoch, ); _currentPoints = [point]; _lastAcceptedPoint = Offset(point.x, point.y); } /// 指针移动:添加采样点(带去抖过滤)。 void _onPointerMove(PointerMoveEvent event) { if (_currentPoints.isEmpty) return; // 掌心抑制 if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) { return; } final candidate = Offset(event.localPosition.dx, event.localPosition.dy); // 轻量去抖:移动距离 < 阈值时忽略(消除手指微抖) if (_lastAcceptedPoint != null) { final distance = (candidate - _lastAcceptedPoint!).distance; if (distance < _debounceThreshold) return; } final point = StrokePoint( x: candidate.dx, y: candidate.dy, pressure: event.pressure, timestamp: DateTime.now().millisecondsSinceEpoch, ); setState(() { _currentPoints = [..._currentPoints, point]; }); _lastAcceptedPoint = candidate; } /// 指针抬起:完成笔画并通过回调传递给上层。 void _onPointerUp(PointerUpEvent event) { if (_currentPoints.isEmpty) return; // 掌心抑制 if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) { return; } // 至少需要 2 个点才能构成有意义的笔画 if (_currentPoints.length < 2) { _currentPoints = const []; _lastAcceptedPoint = null; return; } // 创建不可变的 Stroke 对象 final stroke = Stroke( id: _uuid.v4(), points: List.unmodifiable(_currentPoints), brushType: widget.brushType, color: widget.brushColor, width: widget.brushWidth, ); // 通知上层 widget.onStrokeCompleted?.call(stroke); // 重置当前绘制状态 setState(() { _currentPoints = const []; _lastAcceptedPoint = null; }); } /// 指针取消(如来电打断):丢弃当前笔画。 void _onPointerCancel(PointerCancelEvent event) { setState(() { _currentPoints = const []; _lastAcceptedPoint = null; }); } // ============================================================ // 构建 // ============================================================ @override Widget build(BuildContext context) { // 构造当前正在绘制的笔画(用于实时预览) final currentStroke = _currentPoints.length >= 2 ? Stroke( id: '__current__', // 临时 ID,不会被持久化 points: _currentPoints, brushType: widget.brushType, color: widget.brushColor, width: widget.brushWidth, ) : null; return RepaintBoundary( key: _repaintBoundaryKey, child: ClipRect( child: Listener( behavior: HitTestBehavior.opaque, onPointerDown: _onPointerDown, onPointerMove: _onPointerMove, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, child: CustomPaint( painter: StrokePainter( completedStrokes: widget.strokes, currentStroke: currentStroke, ), // 占满父组件可用空间 size: Size.infinite, ), ), ), ); } }