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)
This commit is contained in:
136
app/lib/features/editor/bloc/editor_bloc.dart
Normal file
136
app/lib/features/editor/bloc/editor_bloc.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../widgets/stroke_model.dart';
|
||||
|
||||
// ===== Events =====
|
||||
|
||||
abstract class EditorEvent {}
|
||||
|
||||
class BrushChanged extends EditorEvent {
|
||||
final BrushType type;
|
||||
final String color;
|
||||
final double width;
|
||||
BrushChanged({required this.type, required this.color, required this.width});
|
||||
}
|
||||
|
||||
class StrokeCompleted extends EditorEvent {
|
||||
final Stroke stroke;
|
||||
StrokeCompleted(this.stroke);
|
||||
}
|
||||
|
||||
class Undo extends EditorEvent {}
|
||||
|
||||
class Redo extends EditorEvent {}
|
||||
|
||||
class ClearCanvas extends EditorEvent {}
|
||||
|
||||
class StrokesLoaded extends EditorEvent {
|
||||
final List<Stroke> strokes;
|
||||
StrokesLoaded(this.strokes);
|
||||
}
|
||||
|
||||
// ===== State =====
|
||||
|
||||
class EditorState {
|
||||
final List<Stroke> strokes;
|
||||
final List<Stroke> redoStack;
|
||||
final BrushType brushType;
|
||||
final String brushColor;
|
||||
final double brushWidth;
|
||||
final int maxUndoSteps;
|
||||
|
||||
const EditorState({
|
||||
this.strokes = const [],
|
||||
this.redoStack = const [],
|
||||
this.brushType = BrushType.pen,
|
||||
this.brushColor = '#2D2420',
|
||||
this.brushWidth = 3.0,
|
||||
this.maxUndoSteps = 50,
|
||||
});
|
||||
|
||||
EditorState copyWith({
|
||||
List<Stroke>? strokes,
|
||||
List<Stroke>? redoStack,
|
||||
BrushType? brushType,
|
||||
String? brushColor,
|
||||
double? brushWidth,
|
||||
}) =>
|
||||
EditorState(
|
||||
strokes: strokes ?? this.strokes,
|
||||
redoStack: redoStack ?? this.redoStack,
|
||||
brushType: brushType ?? this.brushType,
|
||||
brushColor: brushColor ?? this.brushColor,
|
||||
brushWidth: brushWidth ?? this.brushWidth,
|
||||
maxUndoSteps: maxUndoSteps,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== BLoC =====
|
||||
|
||||
class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
EditorBloc() : super(const EditorState()) {
|
||||
on<BrushChanged>(_onBrushChanged);
|
||||
on<StrokeCompleted>(_onStrokeCompleted);
|
||||
on<Undo>(_onUndo);
|
||||
on<Redo>(_onRedo);
|
||||
on<ClearCanvas>(_onClearCanvas);
|
||||
on<StrokesLoaded>(_onStrokesLoaded);
|
||||
}
|
||||
|
||||
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(
|
||||
brushType: event.type,
|
||||
brushColor: event.color,
|
||||
brushWidth: event.width,
|
||||
));
|
||||
}
|
||||
|
||||
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
|
||||
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
|
||||
|
||||
// 超过最大撤销步数时移除最旧的
|
||||
if (updatedStrokes.length > state.maxUndoSteps) {
|
||||
updatedStrokes.removeAt(0);
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: [], // 新笔画清空重做栈
|
||||
));
|
||||
}
|
||||
|
||||
void _onUndo(Undo event, Emitter<EditorState> emit) {
|
||||
if (state.strokes.isEmpty) return;
|
||||
|
||||
final updatedStrokes = List<Stroke>.from(state.strokes);
|
||||
final lastStroke = updatedStrokes.removeLast();
|
||||
final updatedRedoStack = List<Stroke>.from(state.redoStack)..add(lastStroke);
|
||||
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: updatedRedoStack,
|
||||
));
|
||||
}
|
||||
|
||||
void _onRedo(Redo event, Emitter<EditorState> emit) {
|
||||
if (state.redoStack.isEmpty) return;
|
||||
|
||||
final updatedRedoStack = List<Stroke>.from(state.redoStack);
|
||||
final stroke = updatedRedoStack.removeLast();
|
||||
final updatedStrokes = List<Stroke>.from(state.strokes)..add(stroke);
|
||||
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: updatedRedoStack,
|
||||
));
|
||||
}
|
||||
|
||||
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(strokes: [], redoStack: []));
|
||||
}
|
||||
|
||||
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(strokes: event.strokes, redoStack: []));
|
||||
}
|
||||
}
|
||||
242
app/lib/features/editor/widgets/handwriting_canvas.dart
Normal file
242
app/lib/features/editor/widgets/handwriting_canvas.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
/// 手写 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
111
app/lib/features/editor/widgets/stroke_model.dart
Normal file
111
app/lib/features/editor/widgets/stroke_model.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
// 笔画数据模型 — 手写不可变类(避免 build_runner 依赖)
|
||||
|
||||
import 'dart:collection';
|
||||
|
||||
/// 画笔类型
|
||||
enum BrushType {
|
||||
pen('pen'),
|
||||
pencil('pencil'),
|
||||
marker('marker'),
|
||||
eraser('eraser');
|
||||
|
||||
const BrushType(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 笔画点
|
||||
class StrokePoint {
|
||||
const StrokePoint({
|
||||
required this.x,
|
||||
required this.y,
|
||||
this.pressure = 0.5,
|
||||
this.timestamp = 0,
|
||||
});
|
||||
|
||||
final double x;
|
||||
final double y;
|
||||
final double pressure;
|
||||
final int timestamp;
|
||||
|
||||
StrokePoint copyWith({
|
||||
double? x,
|
||||
double? y,
|
||||
double? pressure,
|
||||
int? timestamp,
|
||||
}) =>
|
||||
StrokePoint(
|
||||
x: x ?? this.x,
|
||||
y: y ?? this.y,
|
||||
pressure: pressure ?? this.pressure,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'x': x,
|
||||
'y': y,
|
||||
'pressure': pressure,
|
||||
'timestamp': timestamp,
|
||||
};
|
||||
|
||||
factory StrokePoint.fromJson(Map<String, dynamic> json) => StrokePoint(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
|
||||
timestamp: (json['timestamp'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 笔画
|
||||
class Stroke {
|
||||
const Stroke({
|
||||
required this.id,
|
||||
required this.points,
|
||||
this.brushType = BrushType.pen,
|
||||
this.color = '#2D2420',
|
||||
this.width = 3.0,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final List<StrokePoint> points;
|
||||
final BrushType brushType;
|
||||
final String color;
|
||||
final double width;
|
||||
|
||||
Stroke copyWith({
|
||||
String? id,
|
||||
List<StrokePoint>? points,
|
||||
BrushType? brushType,
|
||||
String? color,
|
||||
double? width,
|
||||
}) =>
|
||||
Stroke(
|
||||
id: id ?? this.id,
|
||||
points: points ?? this.points,
|
||||
brushType: brushType ?? this.brushType,
|
||||
color: color ?? this.color,
|
||||
width: width ?? this.width,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'points': points.map((p) => p.toJson()).toList(),
|
||||
'brushType': brushType.value,
|
||||
'color': color,
|
||||
'width': width,
|
||||
};
|
||||
|
||||
factory Stroke.fromJson(Map<String, dynamic> json) => Stroke(
|
||||
id: json['id'] as String,
|
||||
points: UnmodifiableListView(
|
||||
(json['points'] as List)
|
||||
.map((p) => StrokePoint.fromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
),
|
||||
brushType: BrushType.values.firstWhere(
|
||||
(b) => b.value == json['brushType'],
|
||||
orElse: () => BrushType.pen,
|
||||
),
|
||||
color: (json['color'] as String?) ?? '#2D2420',
|
||||
width: (json['width'] as num?)?.toDouble() ?? 3.0,
|
||||
);
|
||||
}
|
||||
235
app/lib/features/editor/widgets/stroke_renderer.dart
Normal file
235
app/lib/features/editor/widgets/stroke_renderer.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
// 笔画渲染器 — 将 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user