Files
nj/app/lib/features/editor/widgets/handwriting_canvas.dart
iven d0653614e0 feat(diary): 手写引擎 + 日记 CRUD + 同步 API (Phase F3 + B2)
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)
2026-06-01 00:36:05 +08:00

243 lines
7.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 手写 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,
),
),
),
);
}
}