1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged Stream 变更通知,HomeBloc 订阅后自动刷新 2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件, 工具栏检测已激活工具时发出重新激活信号 3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数 (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35) 4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制 半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘 5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势 替换 Pan 手势,支持双指缩放和旋转
311 lines
9.7 KiB
Dart
311 lines
9.7 KiB
Dart
/// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。
|
||
///
|
||
/// 双层渲染架构:
|
||
/// - Layer 1 (CachedStrokesPainter): 已完成笔画的合成位图,仅 drawImage
|
||
/// - Layer 2 (ActiveStrokePainter): 当前正在绘制的笔画,实时计算轮廓
|
||
///
|
||
/// 性能优化:
|
||
/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟)
|
||
/// - 可变 `_currentPoints` 缓冲区 + `ValueNotifier` 驱动 Layer 2 重绘
|
||
/// - Layer 1 仅在笔画完成/撤销/清除时更新(不随指针移动重绘)
|
||
/// - [RepaintBoundary] 隔离重绘范围
|
||
library;
|
||
|
||
import 'package:flutter/gestures.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
|
||
import 'stroke_model.dart';
|
||
import 'stroke_cache.dart';
|
||
import 'cached_strokes_painter.dart';
|
||
import 'active_stroke_painter.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> {
|
||
// ============================================================
|
||
// 状态
|
||
// ============================================================
|
||
|
||
/// 正在绘制中的笔画采样点(可变缓冲区,O(1) 添加)。
|
||
final List<StrokePoint> _currentPoints = [];
|
||
|
||
/// 版本号,每次 pointer move 递增,驱动 ActiveStrokePainter 重绘。
|
||
final ValueNotifier<int> _strokeVersion = ValueNotifier(0);
|
||
|
||
/// 笔画光栅化缓存。
|
||
final StrokeRasterCache _cache = StrokeRasterCache();
|
||
|
||
/// RepaintBoundary 的 key,用于隔离重绘。
|
||
final GlobalKey _repaintBoundaryKey = GlobalKey();
|
||
|
||
/// UUID 生成器实例。
|
||
static const _uuid = Uuid();
|
||
|
||
// ============================================================
|
||
// 掌心抑制
|
||
// ============================================================
|
||
|
||
/// 是否启用掌心抑制。
|
||
bool _palmRejectionEnabled = false;
|
||
|
||
// ============================================================
|
||
// 去抖
|
||
// ============================================================
|
||
|
||
/// 上一个有效采样点的坐标(用于去抖计算)。
|
||
Offset? _lastAcceptedPoint;
|
||
|
||
/// 去抖阈值(像素):移动距离小于此值的点将被过滤。
|
||
static const double _debounceThreshold = 1.0;
|
||
|
||
// ============================================================
|
||
// 生命周期
|
||
// ============================================================
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 初始同步:如果有预加载的笔画(如编辑已有日记)
|
||
if (widget.strokes.isNotEmpty) {
|
||
_syncCacheAfterBuild();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_cache.dispose();
|
||
_strokeVersion.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(HandwritingCanvas oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
// 检测 strokes 列表变化(撤销/重做/清除/外部加载)
|
||
if (!identical(widget.strokes, oldWidget.strokes)) {
|
||
_syncCacheAfterBuild();
|
||
}
|
||
}
|
||
|
||
/// 在 build 完成后同步缓存(避免在 build 中触发异步操作)
|
||
void _syncCacheAfterBuild() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
_cache.syncStrokes(widget.strokes);
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 指针事件处理
|
||
// ============================================================
|
||
|
||
/// 指针按下:开始新笔画。
|
||
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.clear();
|
||
_currentPoints.add(point); // O(1)
|
||
_lastAcceptedPoint = Offset(point.x, point.y);
|
||
_strokeVersion.value++; // 通知 ActiveStrokePainter 重绘
|
||
}
|
||
|
||
/// 指针移动:添加采样点(带去抖过滤)。
|
||
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,
|
||
);
|
||
|
||
_currentPoints.add(point); // O(1) amortized,替代旧的 [...spread]
|
||
_lastAcceptedPoint = candidate;
|
||
_strokeVersion.value++; // 仅驱动 Layer 2 重绘,不触发 setState
|
||
}
|
||
|
||
/// 指针抬起:完成笔画并通过回调传递给上层。
|
||
void _onPointerUp(PointerUpEvent event) {
|
||
if (_currentPoints.isEmpty) return;
|
||
|
||
// 掌心抑制
|
||
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
|
||
return;
|
||
}
|
||
|
||
// 至少需要 2 个点才能构成有意义的笔画
|
||
if (_currentPoints.length < 2) {
|
||
_currentPoints.clear();
|
||
_lastAcceptedPoint = null;
|
||
_strokeVersion.value++; // 清除当前笔画显示
|
||
return;
|
||
}
|
||
|
||
// 创建不可变的 Stroke 对象(快照当前点列表)
|
||
final stroke = Stroke(
|
||
id: _uuid.v4(),
|
||
points: List.unmodifiable(List.of(_currentPoints)),
|
||
brushType: widget.brushType,
|
||
color: widget.brushColor,
|
||
width: widget.brushWidth,
|
||
);
|
||
|
||
// 清除当前绘制状态
|
||
_currentPoints.clear();
|
||
_lastAcceptedPoint = null;
|
||
_strokeVersion.value++; // 清除 Layer 2
|
||
|
||
// 通知上层(BLoC)
|
||
widget.onStrokeCompleted?.call(stroke);
|
||
|
||
// 光栅化新笔画到缓存(异步,不阻塞 UI)
|
||
// 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
|
||
_cache.addStroke(stroke).then((_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
/// 指针取消(如来电打断):丢弃当前笔画。
|
||
void _onPointerCancel(PointerCancelEvent event) {
|
||
_currentPoints.clear();
|
||
_lastAcceptedPoint = null;
|
||
_strokeVersion.value++; // 清除 Layer 2
|
||
}
|
||
|
||
// ============================================================
|
||
// 构建
|
||
// ============================================================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return RepaintBoundary(
|
||
key: _repaintBoundaryKey,
|
||
child: ClipRect(
|
||
child: Listener(
|
||
behavior: HitTestBehavior.opaque,
|
||
onPointerDown: _onPointerDown,
|
||
onPointerMove: _onPointerMove,
|
||
onPointerUp: _onPointerUp,
|
||
onPointerCancel: _onPointerCancel,
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final canvasSize = Size(
|
||
constraints.maxWidth,
|
||
constraints.maxHeight,
|
||
);
|
||
_cache.ensureSize(canvasSize);
|
||
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// Layer 1: 已完成笔画(光栅化缓存合成图)
|
||
CustomPaint(
|
||
painter: CachedStrokesPainter(
|
||
compositeImage: _cache.compositeImage,
|
||
layerVersion: _cache.layerVersion,
|
||
),
|
||
size: Size.infinite,
|
||
),
|
||
|
||
// Layer 2: 当前正在绘制的笔画
|
||
ListenableBuilder(
|
||
listenable: _strokeVersion,
|
||
builder: (context, _) {
|
||
return CustomPaint(
|
||
painter: ActiveStrokePainter(
|
||
points: _currentPoints,
|
||
brushType: widget.brushType,
|
||
color: widget.brushColor,
|
||
width: widget.brushWidth,
|
||
version: _strokeVersion.value,
|
||
),
|
||
size: Size.infinite,
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|