Files
nj/app/lib/features/editor/bloc/editor_bloc.dart
iven a05374e8d1 feat(app): 编辑器增强 — 查看模式 + 图层排序 + 标签/贴纸动态化
- EditorPage 新增查看模式: 打开已保存日记默认只读,编辑按钮切换
- EditorBloc 新增 ElementLayerChanged 事件,支持置顶/置底图层排序
- DraggableElement 添加图层控制按钮 (置顶/置底/删除)
- TagPanel 标签建议改为从日记历史动态生成 (Top 10 频率)
- StickerPickerSheet 重构,预留 API 扩展点
2026-06-07 10:43:37 +08:00

644 lines
18 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.
// 编辑器 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);
}
/// 图层顺序调整方向
enum LayerChange { bringToFront, sendToBack }
/// 调整元素图层顺序
class ElementLayerChanged extends EditorEvent {
final String elementId;
final LayerChange change;
ElementLayerChanged({required this.elementId, required this.change});
}
// --- 工具栏事件 ---
/// 切换活动工具
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<ElementLayerChanged>(_onElementLayerChanged);
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 _onElementLayerChanged(
ElementLayerChanged event,
Emitter<EditorState> emit,
) {
final elements = List<JournalElement>.from(state.elements);
final index = elements.indexWhere((e) => e.id == event.elementId);
if (index == -1) return;
switch (event.change) {
case LayerChange.bringToFront:
// 设为最大 zIndex + 1
final maxZ = elements.fold<int>(
0,
(max, e) => e.zIndex > max ? e.zIndex : max,
);
elements[index] = elements[index].copyWith(zIndex: maxZ + 1);
case LayerChange.sendToBack:
// 设为最小 zIndex - 1
final minZ = elements.fold<int>(
0,
(min, e) => e.zIndex < min ? e.zIndex : min,
);
elements[index] = elements[index].copyWith(zIndex: minZ - 1);
}
emit(state.copyWith(elements: elements, isDirty: true));
_scheduleAutoSave();
}
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(elements: event.elements));
}
// ============================================================
// 日记加载事件处理
// ============================================================
/// 加载已有日记 — 原子操作,一次性还原所有状态
///
/// 不触发 auto-saveisDirty=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()));
}
});
}
}