Flutter 手写引擎 (Phase F3): - stroke_model.dart: 笔画数据模型 (StrokePoint/Stroke/BrushType) - stroke_renderer.dart: perfect_freehand 渲染管线 + 四画笔参数 - handwriting_canvas.dart: Listener 输入 + 掌心抑制 + 去抖过滤 - editor_bloc.dart: BLoC 状态管理 + 撤销/重做 (50步) Rust 日记 CRUD + 同步 (Phase B2): - journal_service.rs: CRUD + 软删除 + 分页列表 + 事件发布 - sync_service.rs: 版本号同步 + 冲突检测 - journal_handler.rs: 5个API端点 + utoipa注解 + 权限守卫 - sync_handler.rs: 同步API端点 - error.rs: From<DiaryError> for AppError + 8个单元测试 - 路由注册: /diary/journals + /diary/sync 验证: - cargo check: 0 error - cargo test: 433 测试全通过 - flutter analyze: 1 warning (unused private param)
243 lines
7.2 KiB
Dart
243 lines
7.2 KiB
Dart
/// 手写 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<EditorBloc>().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<Stroke>? onStrokeCompleted;
|
||
|
||
/// 已有的笔画列表(用于重绘历史内容)。
|
||
final List<Stroke> strokes;
|
||
|
||
@override
|
||
State<HandwritingCanvas> createState() => _HandwritingCanvasState();
|
||
}
|
||
|
||
class _HandwritingCanvasState extends State<HandwritingCanvas> {
|
||
// ============================================================
|
||
// 状态
|
||
// ============================================================
|
||
|
||
/// 正在绘制中的笔画采样点。
|
||
List<StrokePoint> _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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|