feat(app): 实现手账编辑器三层架构 (Phase F4)

新增组件:
- DraggableElement: 可拖拽日记元素组件 (移动/缩放/选中/删除)
- EditorToolbar: 底部工具栏 (8种工具 + 8色 + 5级笔宽 + 撤销/重做)
- EditorStack: 三层 Stack 架构 (Canvas + 元素 + 工具栏)

重写文件:
- editor_bloc.dart: 扩展为完整编辑器 BLoC
  - 元素管理: 添加/删除/移动/缩放/旋转/选中 (7种事件)
  - 工具栏: 8种工具切换 (pen/pencil/marker/eraser/select/text/sticker/image)
  - 自动保存: 2秒 debounce 回调
  - 状态扩展: elements/selectedElementId/activeTool/isDirty
- editor_page.dart: 从占位页面重写为完整编辑器
  - 顶栏: 返回/标题/完成按钮
  - 中间: 三层 Stack (手写层 + 元素层 + 空状态提示)
  - 底部: EditorToolbar
  - 交互逻辑: 画笔模式→Canvas接收, 选择模式→元素层接收

验证: flutter analyze (0 error)
This commit is contained in:
iven
2026-06-01 01:45:35 +08:00
parent 0fe3bc705c
commit 482eb244d5
4 changed files with 1028 additions and 14 deletions

View File

@@ -1,12 +1,27 @@
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
// 编辑器 BLoC — 手写状态管理 + 元素管理 + 撤销/重做 + 自动保存
//
// 状态机:
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/stroke_model.dart';
import '../../../data/models/journal_element.dart';
// ===== Events =====
// ============================================================
// 事件
// ============================================================
/// 编辑器事件基类
abstract class EditorEvent {}
// --- 手写事件 ---
class BrushChanged extends EditorEvent {
final BrushType type;
final String color;
@@ -30,9 +45,90 @@ class StrokesLoaded extends EditorEvent {
StrokesLoaded(this.strokes);
}
// ===== State =====
// --- 元素事件 ---
/// 添加元素(贴纸/照片/文字)
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 ElementsLoaded extends EditorEvent {
final List<JournalElement> elements;
ElementsLoaded(this.elements);
}
// ============================================================
// 状态
// ============================================================
/// 编辑器工具枚举
enum EditorTool {
pen, // 钢笔
pencil, // 铅笔
marker, // 马克笔
eraser, // 橡皮擦
select, // 选择/移动元素
text, // 文字输入
sticker, // 贴纸
image, // 照片
}
/// 编辑器状态
class EditorState {
// 手写层
final List<Stroke> strokes;
final List<Stroke> redoStack;
final BrushType brushType;
@@ -40,6 +136,17 @@ class EditorState {
final double brushWidth;
final int maxUndoSteps;
// 元素层
final List<JournalElement> elements;
final String? selectedElementId;
// 工具栏
final EditorTool activeTool;
// 自动保存
final bool isDirty;
final DateTime? lastSavedAt;
const EditorState({
this.strokes = const [],
this.redoStack = const [],
@@ -47,6 +154,11 @@ class EditorState {
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.maxUndoSteps = 50,
this.elements = const [],
this.selectedElementId,
this.activeTool = EditorTool.pen,
this.isDirty = false,
this.lastSavedAt,
});
EditorState copyWith({
@@ -55,6 +167,12 @@ class EditorState {
BrushType? brushType,
String? brushColor,
double? brushWidth,
List<JournalElement>? elements,
String? selectedElementId,
bool clearSelection = false,
EditorTool? activeTool,
bool? isDirty,
DateTime? lastSavedAt,
}) =>
EditorState(
strokes: strokes ?? this.strokes,
@@ -63,21 +181,75 @@ class EditorState {
brushColor: brushColor ?? this.brushColor,
brushWidth: brushWidth ?? this.brushWidth,
maxUndoSteps: maxUndoSteps,
elements: elements ?? this.elements,
selectedElementId: clearSelection ? null : (selectedElementId ?? this.selectedElementId),
activeTool: activeTool ?? this.activeTool,
isDirty: isDirty ?? this.isDirty,
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
);
/// 是否处于手写模式(画笔/橡皮工具)
bool get isDrawingMode =>
activeTool == EditorTool.pen ||
activeTool == EditorTool.pencil ||
activeTool == EditorTool.marker ||
activeTool == EditorTool.eraser;
/// 是否处于元素操作模式
bool get isElementMode =>
activeTool == EditorTool.select ||
activeTool == EditorTool.text ||
activeTool == EditorTool.sticker ||
activeTool == EditorTool.image;
}
// ===== BLoC =====
// ============================================================
// BLoC
// ============================================================
/// 编辑器 BLoC
class EditorBloc extends Bloc<EditorEvent, EditorState> {
EditorBloc() : super(const 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<ToolChanged>(_onToolChanged);
}
@override
Future<void> close() {
_saveTimer?.cancel();
return super.close();
}
// ============================================================
// 手写事件处理
// ============================================================
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
emit(state.copyWith(
brushType: event.type,
@@ -89,15 +261,16 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
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: [], // 新笔画清空重做栈
redoStack: [],
isDirty: true,
));
_scheduleAutoSave();
}
void _onUndo(Undo event, Emitter<EditorState> emit) {
@@ -110,7 +283,9 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
isDirty: true,
));
_scheduleAutoSave();
}
void _onRedo(Redo event, Emitter<EditorState> emit) {
@@ -123,14 +298,113 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
emit(state.copyWith(
strokes: updatedStrokes,
redoStack: updatedRedoStack,
isDirty: true,
));
_scheduleAutoSave();
}
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
emit(state.copyWith(strokes: [], redoStack: []));
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));
}
// ============================================================
// 工具栏事件处理
// ============================================================
void _onToolChanged(ToolChanged event, Emitter<EditorState> emit) {
// 切换工具时取消元素选中
emit(state.copyWith(
activeTool: event.tool,
clearSelection: true,
));
}
// ============================================================
// 自动保存
// ============================================================
/// 调度自动保存 — debounce 2 秒
void _scheduleAutoSave() {
_saveTimer?.cancel();
_saveTimer = Timer(_saveDelay, () {
if (state.isDirty && onSave != null) {
onSave!(state.copyWith(isDirty: false, lastSavedAt: DateTime.now()));
}
});
}
}