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

329 lines
10 KiB
Dart

// 可拖拽元素组件 — 日记页面中的贴纸/照片/文字交互层
//
// 支持操作:
// - 拖拽移动(单指)
// - 双指缩放
// - 双指旋转
// - 单击选中/取消选中
// - 选中时显示边框和删除按钮
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart';
import '../bloc/editor_bloc.dart' show LayerChange;
/// 可拖拽日记元素组件
class DraggableElement extends StatefulWidget {
final JournalElement element;
final bool isSelected;
final ValueChanged<String> onTap;
final void Function(String id, double x, double y) onMoved;
final void Function(String id, double w, double h)? onResized;
final void Function(String id, double rotation)? onRotated;
final ValueChanged<String> onDeleted;
final void Function(String id, LayerChange change)? onLayerChanged;
const DraggableElement({
super.key,
required this.element,
this.isSelected = false,
required this.onTap,
required this.onMoved,
this.onResized,
this.onRotated,
required this.onDeleted,
this.onLayerChanged,
});
@override
State<DraggableElement> createState() => _DraggableElementState();
}
class _DraggableElementState extends State<DraggableElement> {
late double _x;
late double _y;
late double _width;
late double _height;
late double _rotation;
// Scale 手势状态
double _baseWidth = 0;
double _baseHeight = 0;
double _baseRotation = 0;
@override
void initState() {
super.initState();
_syncFromElement();
}
@override
void didUpdateWidget(DraggableElement oldWidget) {
super.didUpdateWidget(oldWidget);
// 外部更新时同步(如撤销/重做)
if (oldWidget.element.positionX != widget.element.positionX ||
oldWidget.element.positionY != widget.element.positionY ||
oldWidget.element.width != widget.element.width ||
oldWidget.element.height != widget.element.height ||
oldWidget.element.rotation != widget.element.rotation) {
_syncFromElement();
}
}
void _syncFromElement() {
_x = widget.element.positionX;
_y = widget.element.positionY;
_width = widget.element.width;
_height = widget.element.height;
_rotation = widget.element.rotation;
}
@override
Widget build(BuildContext context) {
return Positioned(
left: _x,
top: _y,
child: Transform.rotate(
angle: _rotation,
child: GestureDetector(
// 缩放开始 — 记录基准值
onScaleStart: (details) {
_baseWidth = _width;
_baseHeight = _height;
_baseRotation = _rotation;
},
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
onScaleUpdate: (details) {
setState(() {
// 拖拽(单指和双指都支持)
_x += details.focalPointDelta.dx;
_y += details.focalPointDelta.dy;
// 双指缩放 + 旋转
if (details.pointerCount >= 2) {
final newW = (_baseWidth * details.scale).clamp(40.0, 400.0);
final newH = (_baseHeight * details.scale).clamp(40.0, 400.0);
_width = newW;
_height = newH;
_rotation = _baseRotation + details.rotation;
}
});
widget.onMoved(widget.element.id, _x, _y);
if (details.pointerCount >= 2) {
widget.onResized?.call(widget.element.id, _width, _height);
widget.onRotated?.call(widget.element.id, _rotation);
}
},
onScaleEnd: (_) {
// 确保最终位置已通知
widget.onMoved(widget.element.id, _x, _y);
},
// 点击选中
onTap: () => widget.onTap(widget.element.id),
child: Stack(
clipBehavior: Clip.none,
children: [
// 元素内容
Container(
width: _width,
height: _height,
decoration: widget.isSelected
? BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(4),
)
: null,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: _buildElementContent(context),
),
),
// 选中时显示操作按钮:图层 + 删除
if (widget.isSelected)
Positioned(
top: -12,
right: -12,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 置顶
_ActionButton(
icon: Icons.flip_to_front_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.bringToFront,
),
),
const SizedBox(width: 4),
// 置底
_ActionButton(
icon: Icons.flip_to_back_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.sendToBack,
),
),
const SizedBox(width: 4),
// 删除
_ActionButton(
icon: Icons.close_rounded,
color: Theme.of(context).colorScheme.error,
onTap: () => widget.onDeleted(widget.element.id),
),
],
),
),
],
),
),
),
);
}
/// 根据元素类型构建内容
Widget _buildElementContent(BuildContext context) {
final element = widget.element;
switch (element.elementType) {
case ElementType.text:
final text = element.content['text'] as String? ?? '';
final fontSize = (element.content['fontSize'] as num?)?.toDouble() ?? 18.0;
final fontColor = element.content['fontColor'] as String? ?? '#2D2420';
final color = _parseColor(fontColor);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(
fontSize: fontSize,
color: color,
fontFamily: 'NotoSansSC',
),
maxLines: null,
softWrap: true,
),
);
case ElementType.sticker:
return Container(
color: Colors.transparent,
alignment: Alignment.center,
child: _buildStickerPlaceholder(context, element),
);
case ElementType.image:
final filePath = element.content['filePath'] as String?;
if (filePath != null && filePath.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(filePath),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _buildImagePlaceholder(),
),
);
}
return _buildImagePlaceholder();
case ElementType.tape:
final tapeColor = _parseColor(element.content['tapeColor'] as String?);
return Container(
decoration: BoxDecoration(
color: tapeColor.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(2),
),
);
case ElementType.handwritingRef:
return const SizedBox.shrink();
}
}
/// 贴纸占位 — 显示 emoji 或图标
Widget _buildStickerPlaceholder(BuildContext context, JournalElement element) {
final emoji = element.content['emoji'] as String?;
if (emoji != null) {
return Text(emoji, style: TextStyle(fontSize: _width * 0.6));
}
// 默认贴纸图标
return Icon(
Icons.emoji_emotions_rounded,
size: _width * 0.5,
color: Theme.of(context).colorScheme.tertiary,
);
}
/// 解析颜色字符串
Color _parseColor(String? hex) {
if (hex == null) return const Color(0xFF2D2420);
final hexStr = hex.replaceFirst('#', '');
if (hexStr.length != 6) return const Color(0xFF2D2420);
final value = int.tryParse(hexStr, radix: 16);
if (value == null) return const Color(0xFF2D2420);
return Color(0xFF000000 + value);
}
/// 图片加载失败时的占位符
Widget _buildImagePlaceholder() {
return Container(
width: 160,
height: 120,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined, color: Colors.grey),
SizedBox(height: 4),
Text('图片加载失败', style: TextStyle(color: Colors.grey, fontSize: 11)),
],
),
);
}
}
/// 选中元素的操作按钮(图层/删除)
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionButton({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: Colors.white),
),
);
}
}