Files
nj/app/lib/features/editor/widgets/stroke_renderer.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

236 lines
7.1 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.
// 笔画渲染器 — 将 StrokePoint 列表转换为 Flutter 可绘制路径。
//
// 核心流程:
// StrokePoint[] → perfect_freehand.getStroke() → Point[] (轮廓多边形)
// → buildStrokePath() → Path → CustomPainter.drawPath()
//
// 每种画笔pen/pencil/marker/eraser拥有独立的 perfect_freehand 参数,
// 产生不同的视觉效果。
import 'package:flutter/widgets.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'stroke_model.dart';
// ============================================================
// 画笔参数配置
// ============================================================
/// perfect_freehand 参数集,按画笔类型区分。
///
/// 参考设计规格 v1.2:钢笔细致、铅笔柔和、马克笔粗半透明、橡皮擦大范围。
class _BrushConfig {
final double size;
final double thinning;
final double smoothing;
final double streamline;
final bool simulatePressure;
const _BrushConfig({
required this.size,
required this.thinning,
required this.smoothing,
this.streamline = 0.5,
required this.simulatePressure,
});
}
/// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
BrushType.pen: _BrushConfig(
size: 8,
thinning: 0.7,
smoothing: 0.5,
simulatePressure: true,
),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
BrushType.pencil: _BrushConfig(
size: 4,
thinning: 0.3,
smoothing: 0.7,
simulatePressure: true,
),
/// 马克笔:粗线,几乎无压感变化,不模拟压力
BrushType.marker: _BrushConfig(
size: 16,
thinning: 0.1,
smoothing: 0.5,
simulatePressure: false,
),
/// 橡皮擦:最大范围,无压感变化
BrushType.eraser: _BrushConfig(
size: 32,
thinning: 0,
smoothing: 0.5,
simulatePressure: false,
),
};
// ============================================================
// 点转换工具函数
// ============================================================
/// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。
///
/// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。
/// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。
List<Offset> pointsToOutline(
List<StrokePoint> points,
BrushType brushType,
double width,
) {
if (points.length < 2) return const [];
final config = _brushConfigs[brushType]!;
final widthMultiplier = width / 3.0; // 3.0 是默认宽度,缩放到用户选择
// 转换为 perfect_freehand 的 Point 格式
final pfPoints = points
.map((p) => pf.Point(p.x, p.y, p.pressure))
.toList(growable: false);
// 调用 getStroke 获取轮廓多边形
final outline = pf.getStroke(
pfPoints,
size: config.size * widthMultiplier,
thinning: config.thinning,
smoothing: config.smoothing,
streamline: config.streamline,
simulatePressure: config.simulatePressure,
isComplete: true, // 已完成笔画,启用端点处理
);
// 转换为 Flutter Offset
return outline.map((p) => Offset(p.x, p.y)).toList(growable: false);
}
// ============================================================
// 路径构建
// ============================================================
/// 将轮廓点列表构建为 Flutter [Path]。
///
/// 使用 [Path.addPolygon] 直接构建闭合多边形,
/// 比 lineTo 逐点连接更高效,且自动闭合。
Path buildStrokePath(List<Offset> outlinePoints) {
if (outlinePoints.isEmpty) return Path();
final path = Path();
path.addPolygon(outlinePoints, true); // true = 自动闭合
return path;
}
// ============================================================
// 颜色解析工具
// ============================================================
/// 将 CSS 十六进制颜色字符串 (#RRGGBB) 解析为 Flutter [Color]。
///
/// 如果解析失败,回退到默认的深色文字色 #2D2420。
Color _parseHexColor(String hex) {
final hexStr = hex.replaceFirst('#', '');
if (hexStr.length != 6) return const Color(0xFF2D2420);
final value = int.tryParse(hexStr, radix: 16);
if (value == null) return const Color(0xFF2D2420);
return Color(0xFF000000 + value);
}
// ============================================================
// 笔画绘制器
// ============================================================
/// 自定义 [CustomPainter],负责将所有笔画渲染到 Canvas。
///
/// 接收 [completedStrokes](已完成笔画列表)和可选的 [currentStroke]
/// (正在绘制中的笔画),分别渲染。
///
/// 渲染策略:
/// - 钢笔/铅笔:正常不透明绘制
/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔效果
/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除底层内容
class StrokePainter extends CustomPainter {
final List<Stroke> completedStrokes;
final Stroke? currentStroke;
StrokePainter({
required this.completedStrokes,
this.currentStroke,
});
@override
void paint(Canvas canvas, Size size) {
// 先绘制已完成的笔画
for (final stroke in completedStrokes) {
_drawStroke(canvas, stroke);
}
// 再绘制正在进行的笔画(覆盖在已完成笔画之上)
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
/// 渲染单条笔画。
void _drawStroke(Canvas canvas, Stroke stroke) {
final outlinePoints = pointsToOutline(
stroke.points,
stroke.brushType,
stroke.width,
);
if (outlinePoints.isEmpty) return;
final path = buildStrokePath(outlinePoints);
// 根据画笔类型选择不同的绘制策略
final paint = _createPaint(stroke);
canvas.drawPath(path, paint);
}
/// 根据画笔类型创建对应的 [Paint] 对象。
Paint _createPaint(Stroke stroke) {
final color = _parseHexColor(stroke.color);
switch (stroke.brushType) {
case BrushType.pen:
case BrushType.pencil:
// 钢笔和铅笔:不透明实心绘制
return Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.marker:
// 马克笔:半透明模拟荧光笔
return Paint()
..color = color.withValues(alpha: 0.4)
..style = PaintingStyle.fill
..isAntiAlias = true;
case BrushType.eraser:
// 橡皮擦:使用 dstOut 混合模式擦除底层像素
return Paint()
..color = const Color(0xFFFFFFFF) // 颜色无关紧要blendMode 决定效果
..style = PaintingStyle.fill
..blendMode = BlendMode.dstOut
..isAntiAlias = true;
}
}
/// 判断是否需要重绘。
///
/// 始终比较引用:当 strokes 列表或 currentStroke 发生变化时重绘。
/// 这是最安全的策略因为笔画数据是不可变的freezed
/// 引用变化意味着内容一定变化。
@override
bool shouldRepaint(StrokePainter oldDelegate) {
return !identical(completedStrokes, oldDelegate.completedStrokes) ||
!identical(currentStroke, oldDelegate.currentStroke);
}
}