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 手势,支持双指缩放和旋转
603 lines
17 KiB
Dart
603 lines
17 KiB
Dart
// 编辑器 BLoC — 手写状态管理 + 元素管理 + 撤销/重做 + 自动保存
|
||
//
|
||
// 状态机:
|
||
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
|
||
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
|
||
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
|
||
// - 标签/心情:日记标签管理 + 心情选择 + 标题编辑
|
||
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
|
||
import '../widgets/stroke_model.dart';
|
||
import '../../../data/models/journal_entry.dart';
|
||
import '../../../data/models/journal_element.dart';
|
||
|
||
// ============================================================
|
||
// 事件
|
||
// ============================================================
|
||
|
||
/// 编辑器事件基类
|
||
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);
|
||
}
|
||
|
||
// --- 元素事件 ---
|
||
|
||
/// 添加元素(贴纸/照片/文字)
|
||
class ElementAdded extends EditorEvent {
|
||
final JournalElement element;
|
||
ElementAdded(this.element);
|
||
}
|
||
|
||
/// 删除元素
|
||
class ElementRemoved extends EditorEvent {
|
||
final String elementId;
|
||
ElementRemoved(this.elementId);
|
||
}
|
||
|
||
/// 元素位置更新(拖拽中)
|
||
class ElementMoved extends EditorEvent {
|
||
final String elementId;
|
||
final double positionX;
|
||
final double positionY;
|
||
ElementMoved({
|
||
required this.elementId,
|
||
required this.positionX,
|
||
required this.positionY,
|
||
});
|
||
}
|
||
|
||
/// 元素尺寸更新(缩放中)
|
||
class ElementResized extends EditorEvent {
|
||
final String elementId;
|
||
final double width;
|
||
final double height;
|
||
ElementResized({
|
||
required this.elementId,
|
||
required this.width,
|
||
required this.height,
|
||
});
|
||
}
|
||
|
||
/// 元素旋转更新
|
||
class ElementRotated extends EditorEvent {
|
||
final String elementId;
|
||
final double rotation;
|
||
ElementRotated({required this.elementId, required this.rotation});
|
||
}
|
||
|
||
/// 选中/取消选中元素
|
||
class ElementSelected extends EditorEvent {
|
||
final String? elementId;
|
||
ElementSelected(this.elementId);
|
||
}
|
||
|
||
// --- 工具栏事件 ---
|
||
|
||
/// 切换活动工具
|
||
class ToolChanged extends EditorEvent {
|
||
final EditorTool tool;
|
||
ToolChanged(this.tool);
|
||
}
|
||
|
||
/// 再次点击已激活的工具 — 重新弹出设置面板
|
||
class ToolReactivated extends EditorEvent {
|
||
final EditorTool tool;
|
||
ToolReactivated(this.tool);
|
||
}
|
||
|
||
/// 加载已有元素
|
||
class ElementsLoaded extends EditorEvent {
|
||
final List<JournalElement> elements;
|
||
ElementsLoaded(this.elements);
|
||
}
|
||
|
||
// --- 标签/心情/标题事件 ---
|
||
|
||
/// 添加标签
|
||
class TagAdded extends EditorEvent {
|
||
final String tag;
|
||
TagAdded(this.tag);
|
||
}
|
||
|
||
/// 移除标签
|
||
class TagRemoved extends EditorEvent {
|
||
final String tag;
|
||
TagRemoved(this.tag);
|
||
}
|
||
|
||
/// 加载已有标签
|
||
class TagsLoaded extends EditorEvent {
|
||
final List<String> tags;
|
||
TagsLoaded(this.tags);
|
||
}
|
||
|
||
/// 心情变更
|
||
class MoodChanged extends EditorEvent {
|
||
final Mood mood;
|
||
MoodChanged(this.mood);
|
||
}
|
||
|
||
/// 标题变更
|
||
class TitleChanged extends EditorEvent {
|
||
final String title;
|
||
TitleChanged(this.title);
|
||
}
|
||
|
||
/// 文字格式变更
|
||
class TextFormatChanged extends EditorEvent {
|
||
final String elementId;
|
||
final bool? bold;
|
||
final bool? italic;
|
||
final bool? underline;
|
||
final String? color;
|
||
final TextAlign? alignment;
|
||
TextFormatChanged({
|
||
required this.elementId,
|
||
this.bold,
|
||
this.italic,
|
||
this.underline,
|
||
this.color,
|
||
this.alignment,
|
||
});
|
||
}
|
||
|
||
/// 加载已有日记数据(从 JournalRepository 读取后原子注入)
|
||
///
|
||
/// 与 StrokesLoaded/ElementsLoaded/TagsLoaded 等细粒度事件不同,
|
||
/// LoadJournal 一次性还原所有日记状态,不触发 auto-save (isDirty=false)。
|
||
class LoadJournal extends EditorEvent {
|
||
final String title;
|
||
final Mood mood;
|
||
final List<String> tags;
|
||
final List<Stroke> strokes;
|
||
final List<JournalElement> elements;
|
||
final DateTime? lastSavedAt;
|
||
|
||
LoadJournal({
|
||
required this.title,
|
||
required this.mood,
|
||
required this.tags,
|
||
required this.strokes,
|
||
required this.elements,
|
||
this.lastSavedAt,
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 状态
|
||
// ============================================================
|
||
|
||
/// 编辑器工具枚举 — 对应底部 6 个工具按钮 + 内部 select 模式
|
||
enum EditorTool {
|
||
sticker, // 贴纸
|
||
template, // 模板
|
||
brush, // 画笔(含钢笔/铅笔/马克笔/橡皮子类型)
|
||
photo, // 照片
|
||
text, // 文字
|
||
more, // 更多
|
||
select, // 选择/移动元素(内部模式,非 UI 按钮)
|
||
}
|
||
|
||
/// 编辑器状态
|
||
class EditorState {
|
||
// 手写层
|
||
final List<Stroke> strokes;
|
||
final List<Stroke> redoStack;
|
||
final BrushType brushType;
|
||
final String brushColor;
|
||
final double brushWidth;
|
||
final double brushOpacity;
|
||
final int maxUndoSteps;
|
||
|
||
// 元素层
|
||
final List<JournalElement> elements;
|
||
final String? selectedElementId;
|
||
|
||
// 工具栏
|
||
final EditorTool activeTool;
|
||
|
||
// 标签/心情/标题
|
||
final List<String> tags;
|
||
final Mood selectedMood;
|
||
final String title;
|
||
|
||
// 自动保存
|
||
final bool isDirty;
|
||
final DateTime? lastSavedAt;
|
||
|
||
// 工具重新激活时间戳(用于驱动面板重新弹出)
|
||
final int toolReactivatedAt;
|
||
|
||
const EditorState({
|
||
this.strokes = const [],
|
||
this.redoStack = const [],
|
||
this.brushType = BrushType.pen,
|
||
this.brushColor = '#2D2420',
|
||
this.brushWidth = 3.0,
|
||
this.brushOpacity = 1.0,
|
||
this.maxUndoSteps = 50,
|
||
this.elements = const [],
|
||
this.selectedElementId,
|
||
this.activeTool = EditorTool.brush,
|
||
this.tags = const [],
|
||
this.selectedMood = Mood.calm,
|
||
this.title = '',
|
||
this.isDirty = false,
|
||
this.lastSavedAt,
|
||
this.toolReactivatedAt = 0,
|
||
});
|
||
|
||
EditorState copyWith({
|
||
List<Stroke>? strokes,
|
||
List<Stroke>? redoStack,
|
||
BrushType? brushType,
|
||
String? brushColor,
|
||
double? brushWidth,
|
||
double? brushOpacity,
|
||
List<JournalElement>? elements,
|
||
String? selectedElementId,
|
||
bool clearSelection = false,
|
||
EditorTool? activeTool,
|
||
List<String>? tags,
|
||
Mood? selectedMood,
|
||
String? title,
|
||
bool? isDirty,
|
||
DateTime? lastSavedAt,
|
||
int? toolReactivatedAt,
|
||
}) =>
|
||
EditorState(
|
||
strokes: strokes ?? this.strokes,
|
||
redoStack: redoStack ?? this.redoStack,
|
||
brushType: brushType ?? this.brushType,
|
||
brushColor: brushColor ?? this.brushColor,
|
||
brushWidth: brushWidth ?? this.brushWidth,
|
||
brushOpacity: brushOpacity ?? this.brushOpacity,
|
||
maxUndoSteps: maxUndoSteps,
|
||
elements: elements ?? this.elements,
|
||
selectedElementId:
|
||
clearSelection ? null : (selectedElementId ?? this.selectedElementId),
|
||
activeTool: activeTool ?? this.activeTool,
|
||
tags: tags ?? this.tags,
|
||
selectedMood: selectedMood ?? this.selectedMood,
|
||
title: title ?? this.title,
|
||
isDirty: isDirty ?? this.isDirty,
|
||
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
||
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
|
||
);
|
||
|
||
/// 是否处于手写模式
|
||
bool get isDrawingMode => activeTool == EditorTool.brush;
|
||
|
||
/// 是否处于元素操作模式
|
||
bool get isElementMode =>
|
||
activeTool == EditorTool.select ||
|
||
activeTool == EditorTool.text ||
|
||
activeTool == EditorTool.sticker ||
|
||
activeTool == EditorTool.photo;
|
||
}
|
||
|
||
// ============================================================
|
||
// BLoC
|
||
// ============================================================
|
||
|
||
/// 编辑器 BLoC
|
||
class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||
/// 自动保存回调 — 由上层(EditorPage)提供
|
||
final void Function(EditorState state)? onSave;
|
||
|
||
/// 自动保存 debounce 定时器
|
||
Timer? _saveTimer;
|
||
|
||
/// 自动保存延迟
|
||
static const _saveDelay = Duration(seconds: 2);
|
||
|
||
EditorBloc({this.onSave}) : super(const EditorState()) {
|
||
// 手写事件
|
||
on<BrushChanged>(_onBrushChanged);
|
||
on<StrokeCompleted>(_onStrokeCompleted);
|
||
on<Undo>(_onUndo);
|
||
on<Redo>(_onRedo);
|
||
on<ClearCanvas>(_onClearCanvas);
|
||
on<StrokesLoaded>(_onStrokesLoaded);
|
||
|
||
// 元素事件
|
||
on<ElementAdded>(_onElementAdded);
|
||
on<ElementRemoved>(_onElementRemoved);
|
||
on<ElementMoved>(_onElementMoved);
|
||
on<ElementResized>(_onElementResized);
|
||
on<ElementRotated>(_onElementRotated);
|
||
on<ElementSelected>(_onElementSelected);
|
||
on<ElementsLoaded>(_onElementsLoaded);
|
||
|
||
// 日记加载事件
|
||
on<LoadJournal>(_onLoadJournal);
|
||
|
||
// 工具栏事件
|
||
on<ToolChanged>(_onToolChanged);
|
||
on<ToolReactivated>(_onToolReactivated);
|
||
|
||
// 标签/心情/标题事件
|
||
on<TagAdded>(_onTagAdded);
|
||
on<TagRemoved>(_onTagRemoved);
|
||
on<TagsLoaded>(_onTagsLoaded);
|
||
on<MoodChanged>(_onMoodChanged);
|
||
on<TitleChanged>(_onTitleChanged);
|
||
on<TextFormatChanged>(_onTextFormatChanged);
|
||
}
|
||
|
||
@override
|
||
Future<void> close() {
|
||
_saveTimer?.cancel();
|
||
return super.close();
|
||
}
|
||
|
||
// ============================================================
|
||
// 手写事件处理
|
||
// ============================================================
|
||
|
||
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: [],
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
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,
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
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,
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(
|
||
strokes: [],
|
||
redoStack: [],
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(strokes: event.strokes, redoStack: []));
|
||
}
|
||
|
||
// ============================================================
|
||
// 元素事件处理
|
||
// ============================================================
|
||
|
||
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
|
||
final updated =
|
||
List<JournalElement>.from(state.elements)..add(event.element);
|
||
emit(state.copyWith(
|
||
elements: updated,
|
||
selectedElementId: event.element.id,
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onElementRemoved(ElementRemoved event, Emitter<EditorState> emit) {
|
||
final updated = List<JournalElement>.from(state.elements)
|
||
..removeWhere((e) => e.id == event.elementId);
|
||
emit(state.copyWith(
|
||
elements: updated,
|
||
clearSelection: state.selectedElementId == event.elementId,
|
||
isDirty: true,
|
||
));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onElementMoved(ElementMoved event, Emitter<EditorState> emit) {
|
||
final updated = state.elements.map((e) {
|
||
if (e.id != event.elementId) return e;
|
||
return e.copyWith(
|
||
positionX: event.positionX,
|
||
positionY: event.positionY,
|
||
);
|
||
}).toList();
|
||
emit(state.copyWith(elements: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onElementResized(ElementResized event, Emitter<EditorState> emit) {
|
||
final updated = state.elements.map((e) {
|
||
if (e.id != event.elementId) return e;
|
||
return e.copyWith(width: event.width, height: event.height);
|
||
}).toList();
|
||
emit(state.copyWith(elements: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onElementRotated(ElementRotated event, Emitter<EditorState> emit) {
|
||
final updated = state.elements.map((e) {
|
||
if (e.id != event.elementId) return e;
|
||
return e.copyWith(rotation: event.rotation);
|
||
}).toList();
|
||
emit(state.copyWith(elements: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onElementSelected(ElementSelected event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(
|
||
selectedElementId: event.elementId,
|
||
clearSelection: event.elementId == null,
|
||
));
|
||
}
|
||
|
||
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(elements: event.elements));
|
||
}
|
||
|
||
// ============================================================
|
||
// 日记加载事件处理
|
||
// ============================================================
|
||
|
||
/// 加载已有日记 — 原子操作,一次性还原所有状态
|
||
///
|
||
/// 不触发 auto-save(isDirty=false),因为这是加载而非用户编辑。
|
||
void _onLoadJournal(LoadJournal event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(
|
||
title: event.title,
|
||
selectedMood: event.mood,
|
||
tags: event.tags,
|
||
strokes: event.strokes,
|
||
elements: event.elements,
|
||
lastSavedAt: event.lastSavedAt,
|
||
isDirty: false,
|
||
));
|
||
}
|
||
|
||
// ============================================================
|
||
// 工具栏事件处理
|
||
// ============================================================
|
||
|
||
void _onToolChanged(ToolChanged event, Emitter<EditorState> emit) {
|
||
// 切换工具时取消元素选中
|
||
emit(state.copyWith(
|
||
activeTool: event.tool,
|
||
clearSelection: true,
|
||
));
|
||
}
|
||
|
||
void _onToolReactivated(ToolReactivated event, Emitter<EditorState> emit) {
|
||
// 不改变 activeTool,仅递增时间戳驱动 UI 层重新弹出面板
|
||
emit(state.copyWith(
|
||
toolReactivatedAt: DateTime.now().millisecondsSinceEpoch,
|
||
));
|
||
}
|
||
|
||
// ============================================================
|
||
// 标签/心情/标题事件处理
|
||
// ============================================================
|
||
|
||
void _onTagAdded(TagAdded event, Emitter<EditorState> emit) {
|
||
if (state.tags.contains(event.tag)) return;
|
||
if (state.tags.length >= 10) return; // 设计 Token: maxTags=10
|
||
final updated = List<String>.from(state.tags)..add(event.tag);
|
||
emit(state.copyWith(tags: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onTagRemoved(TagRemoved event, Emitter<EditorState> emit) {
|
||
final updated = List<String>.from(state.tags)..remove(event.tag);
|
||
emit(state.copyWith(tags: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onTagsLoaded(TagsLoaded event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(tags: event.tags));
|
||
}
|
||
|
||
void _onMoodChanged(MoodChanged event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(selectedMood: event.mood, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onTitleChanged(TitleChanged event, Emitter<EditorState> emit) {
|
||
emit(state.copyWith(title: event.title, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
void _onTextFormatChanged(
|
||
TextFormatChanged event,
|
||
Emitter<EditorState> emit,
|
||
) {
|
||
final updated = state.elements.map((e) {
|
||
if (e.id != event.elementId) return e;
|
||
final content = Map<String, dynamic>.from(e.content);
|
||
if (event.bold != null) content['bold'] = event.bold;
|
||
if (event.italic != null) content['italic'] = event.italic;
|
||
if (event.underline != null) content['underline'] = event.underline;
|
||
if (event.color != null) content['color'] = event.color;
|
||
if (event.alignment != null) {
|
||
content['alignment'] = event.alignment!.index;
|
||
}
|
||
return e.copyWith(content: content);
|
||
}).toList();
|
||
emit(state.copyWith(elements: updated, isDirty: true));
|
||
_scheduleAutoSave();
|
||
}
|
||
|
||
// ============================================================
|
||
// 自动保存
|
||
// ============================================================
|
||
|
||
/// 调度自动保存 — debounce 2 秒
|
||
void _scheduleAutoSave() {
|
||
_saveTimer?.cancel();
|
||
_saveTimer = Timer(_saveDelay, () {
|
||
if (state.isDirty && onSave != null) {
|
||
onSave!(state.copyWith(isDirty: false, lastSavedAt: DateTime.now()));
|
||
}
|
||
});
|
||
}
|
||
}
|