Files
nj/app/lib/features/editor/widgets/handwriting_canvas.dart
iven 9fce34f4ef
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(app): 修复 4 个 Flutter 交互问题
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 手势,支持双指缩放和旋转
2026-06-04 00:05:22 +08:00

311 lines
9.7 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 主组件 — 暖记手写引擎的用户交互层。
///
/// 双层渲染架构:
/// - 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,
);
},
),
],
);
},
),
),
),
);
}
}