Files
nj/app/lib/features/editor/widgets/draggable_element.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

282 lines
8.7 KiB
Dart

// 可拖拽元素组件 — 日记页面中的贴纸/照片/文字交互层
//
// 支持操作:
// - 拖拽移动(单指)
// - 双指缩放
// - 双指旋转
// - 单击选中/取消选中
// - 选中时显示边框和删除按钮
import 'dart:io';
import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart';
/// 可拖拽日记元素组件
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;
const DraggableElement({
super.key,
required this.element,
this.isSelected = false,
required this.onTap,
required this.onMoved,
this.onResized,
this.onRotated,
required this.onDeleted,
});
@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: GestureDetector(
onTap: () => widget.onDeleted(widget.element.id),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close_rounded,
size: 16,
color: Colors.white,
),
),
),
),
],
),
),
),
);
}
/// 根据元素类型构建内容
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)),
],
),
);
}
}