Files
MaliangAINovalWriter/AINoval/lib/screens/editor/widgets/selection_toolbar.dart
2025-09-10 00:07:52 +08:00

1954 lines
70 KiB
Dart
Raw Permalink 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.

// import 'dart:math' as math;
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'dart:async';
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
import 'package:ainoval/screens/editor/widgets/snippet_edit_form.dart';
import 'package:ainoval/screens/editor/components/text_generation_dialogs.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/widgets/common/preset_quick_menu_refactored.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/utils/logger.dart';
// import 'package:ainoval/config/provider_icons.dart';
import 'package:ainoval/utils/web_theme.dart';
import '../../../config/app_config.dart';
/// 统一的工具栏菜单组件
class ToolbarMenuButton<T> extends StatelessWidget {
const ToolbarMenuButton({
super.key,
required this.icon,
required this.tooltip,
required this.items,
required this.onSelected,
required this.isDark,
this.isActive = false,
});
final IconData icon;
final String tooltip;
final List<ToolbarMenuItem<T>> items;
final ValueChanged<T?> onSelected;
final bool isDark;
final bool isActive;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: true,
child: PopupMenuButton<T>(
padding: EdgeInsets.zero,
position: PopupMenuPosition.under, // 菜单出现在按钮下方
color: WebTheme.getBackgroundColor(context), // 主题背景色
elevation: 1, // 减少阴影
shadowColor: Theme.of(context).colorScheme.shadow.withOpacity(0.12), // 主题阴影色
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200,
width: 1,
),
),
offset: const Offset(0, 2), // 微小偏移确保不覆盖按钮
itemBuilder: (context) => items.map<PopupMenuEntry<T>>((item) {
if (item.isDivider) {
return const PopupMenuDivider();
}
return PopupMenuItem<T>(
value: item.value,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: true,
child: item.child,
),
);
}).toList(),
onSelected: onSelected,
child: Container(
padding: const EdgeInsets.all(8),
decoration: isActive ? BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
) : null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isActive
? WebTheme.getPrimaryColor(context)
: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 2),
Icon(
Icons.expand_more,
size: 12,
color: isActive
? WebTheme.getPrimaryColor(context)
: WebTheme.getSecondaryTextColor(context),
),
],
),
),
),
),
);
}
}
/// 工具栏菜单项
class ToolbarMenuItem<T> {
const ToolbarMenuItem({
required this.value,
required this.child,
this.isDivider = false,
});
/// 创建分隔线
const ToolbarMenuItem.divider()
: value = null,
child = const SizedBox.shrink(),
isDivider = true;
final T? value;
final Widget child;
final bool isDivider;
}
/// 颜色菜单项组件
class ColorMenuItem extends StatelessWidget {
const ColorMenuItem({
super.key,
required this.color,
required this.label,
});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: color == WebTheme.getBackgroundColor(context)
? Border.all(color: WebTheme.isDarkMode(context) ? WebTheme.darkGrey200 : WebTheme.grey200, width: 1)
: null,
),
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: WebTheme.getTextColor(context),
fontSize: 14,
),
),
],
);
}
}
/// 文本选中上下文工具栏
///
/// 当用户在编辑器中选中文本时显示的浮动工具栏,提供格式化和自定义操作按钮
class SelectionToolbar extends StatefulWidget {
/// 创建一个选中工具栏
///
/// [controller] 富文本编辑器控制器
/// [layerLink] 用于定位工具栏的层链接
/// [onClosed] 工具栏关闭时的回调
/// [onFormatChanged] 格式变更时的回调
/// [wordCount] 选中文本的字数
/// [showAbove] 是否显示在选区上方默认为true
/// [scrollController] 滚动控制器,用于检测滚动位置
/// [novelId] 小说ID用于创建设定和片段
/// [onSettingCreated] 设定创建成功回调
/// [onSnippetCreated] 片段创建成功回调
/// [onStreamingGenerationStarted] 流式生成开始回调
const SelectionToolbar({
super.key,
required this.controller,
required this.layerLink,
required this.editorSize,
required this.selectionRect,
this.onClosed,
this.onFormatChanged,
this.wordCount = 0,
this.showAbove = true,
this.scrollController,
this.novelId,
this.onSettingCreated,
this.onSnippetCreated,
this.onStreamingGenerationStarted,
this.novel,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.targetKey,
});
/// 富文本编辑器控制器
final QuillController controller;
/// 用于定位工具栏的层链接
final LayerLink layerLink;
/// 编辑器尺寸
final Size editorSize;
/// 选区矩形
final Rect selectionRect;
/// 工具栏关闭时的回调
final VoidCallback? onClosed;
/// 格式变更时的回调
final VoidCallback? onFormatChanged;
/// 选中文本的字数
final int wordCount;
/// 是否显示在选区上方默认为true
final bool showAbove;
/// 滚动控制器,用于检测滚动位置
final ScrollController? scrollController;
/// 小说ID用于创建设定和片段
final String? novelId;
/// 设定创建成功回调
final Function(NovelSettingItem)? onSettingCreated;
/// 片段创建成功回调
final Function(NovelSnippet)? onSnippetCreated;
/// 流式生成开始回调 - 支持统一AI模型
final Function(UniversalAIRequest request, UnifiedAIModel model)? onStreamingGenerationStarted;
/// 小说数据用于AI功能的上下文
final Novel? novel;
/// 设定数据用于AI功能的上下文
final List<NovelSettingItem> settings;
/// 设定组数据用于AI功能的上下文
final List<SettingGroup> settingGroups;
/// 片段数据用于AI功能的上下文
final List<NovelSnippet> snippets;
/// LayerLink目标对应的GlobalKey用于计算全局位置
final GlobalKey? targetKey;
@override
State<SelectionToolbar> createState() => _SelectionToolbarState();
}
class _SelectionToolbarState extends State<SelectionToolbar> {
late final FocusNode _toolbarFocusNode;
final GlobalKey _toolbarKey = GlobalKey();
// 行间距常量,用于计算工具栏与文本的距离
static const double _lineSpacing = 6.0;
// 工具栏高度预估(用于位置计算)
static const double _defaultToolbarHeight = 120.0;
double _toolbarHeight = _defaultToolbarHeight;
// AI功能相关状态
OverlayEntry? _aiMenuOverlay;
final Map<String, GlobalKey> _aiButtonKeys = {
'expand': GlobalKey(),
'rewrite': GlobalKey(),
'compress': GlobalKey(),
};
String? _currentAiMode; // 当前AI操作模式'expand', 'rewrite', 'compress'
UserAIModelConfigModel? _selectedModel; // 保持向后兼容
UnifiedAIModel? _selectedUnifiedModel; // 新的统一模型
// 🚀 新增:保存工具栏出现时的选区,防止点击按钮后选区丢失导致无法应用格式
late final TextSelection _initialSelection;
// 🚀 新增:滚动监听,滚动时重新计算工具栏位置
Timer? _scrollDebounce;
// ==================== 动画相关状态 ====================
// 上一次计算得到的工具栏偏移,用于在新旧偏移之间做插值动画
Offset _lastOffset = Offset.zero;
// 第一帧无需动画,避免工具栏从(0,0)滑入导致闪烁
bool _firstBuild = true;
@override
void initState() {
super.initState();
_toolbarFocusNode = FocusNode();
// 记录工具栏打开时的选区
_initialSelection = widget.controller.selection;
// 初始化后计算位置
WidgetsBinding.instance.addPostFrameCallback((_) => _updateToolbarHeight());
_attachScrollListener();
}
@override
void didUpdateWidget(covariant SelectionToolbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.scrollController != widget.scrollController) {
_detachScrollListener(oldWidget.scrollController);
_attachScrollListener();
}
}
void _attachScrollListener() {
widget.scrollController?.addListener(_handleScroll);
}
void _detachScrollListener(ScrollController? controller) {
controller?.removeListener(_handleScroll);
}
void _handleScroll() {
// 使用微节流减少setState调用频率
_scrollDebounce?.cancel();
_scrollDebounce = Timer(const Duration(milliseconds: 50), () {
if (mounted) setState(() {}); // 触发重建,重新计算偏移
});
}
void _updateToolbarHeight() {
if (_toolbarKey.currentContext != null) {
final h = (_toolbarKey.currentContext!.findRenderObject() as RenderBox).size.height;
if ((h - _toolbarHeight).abs() > 1) {
setState(() {
_toolbarHeight = h;
});
}
}
}
void _adjustPosition() {
// 获取工具栏尺寸
final RenderBox? toolbarBox =
_toolbarKey.currentContext?.findRenderObject() as RenderBox?;
if (toolbarBox == null) return;
// 通知父组件调整位置(如果需要)
if (widget.onFormatChanged != null) {
widget.onFormatChanged!();
}
}
/// 检查选中区域是否在前三行
bool _isSelectionInFirstThreeLines() {
try {
// 🚀 使用与_applyAttribute相同的逻辑获取选区确保一致性
TextSelection selection = widget.controller.selection;
if (selection.isCollapsed) {
// 如果当前选区已折叠,使用初始选区
selection = _initialSelection;
}
if (selection.isCollapsed) {
return false;
}
// 获取文档内容
final document = widget.controller.document;
// 获取选区开始位置之前的文本
final String textBeforeSelection = document.getPlainText(0, selection.start);
// 计算换行符数量来判断行数
final lineBreakCount = '\n'.allMatches(textBeforeSelection).length;
// 行数 = 换行符数量 + 1 (因为第一行没有换行符)
// 前三行的行数范围是 1, 2, 3对应换行符数量为 0, 1, 2
final lineNumber = lineBreakCount + 1;
AppLogger.d('SelectionToolbar', '选区开始位置在第 $lineNumber 行(换行符数量: $lineBreakCount');
return lineNumber <= 3;
} catch (e) {
AppLogger.e('SelectionToolbar', '检查选区行数失败: $e');
return false;
}
}
/// 计算工具栏应该显示的位置偏移
/// 基于视窗坐标系通过LayerLink获取选区相对于视窗的偏移量
Offset _calculateToolbarOffset() {
try {
AppLogger.d('SelectionToolbar', '🚀 开始计算工具栏位置偏移基于视窗坐标系不使用selectionRect和TextPainter');
final selection = widget.controller.selection;
AppLogger.d('SelectionToolbar', '📝 文本选择状态: start=${selection.start}, end=${selection.end}, isCollapsed=${selection.isCollapsed}');
if (selection.isCollapsed) {
AppLogger.d('SelectionToolbar', '❌ 选择已折叠,返回默认位置 Offset(0, -60)');
return const Offset(0, -60); // 默认位置
}
// 步骤1: 获取视窗尺寸信息
final viewportSize = MediaQuery.of(context).size;
AppLogger.d('SelectionToolbar', '📱 视窗尺寸: width=${viewportSize.width}, height=${viewportSize.height}');
// 步骤2: 通过LayerLink获取目标组件的位置信息
AppLogger.d('SelectionToolbar', '🔗 使用LayerLink作为定位基准LayerLink会自动跟踪选择区域位置');
// 步骤3: 获取当前滚动位置
double scrollOffset = 0.0;
if (widget.scrollController != null && widget.scrollController!.hasClients) {
scrollOffset = widget.scrollController!.offset;
AppLogger.d('SelectionToolbar', '📜 滚动控制器状态: 有客户端连接,滚动偏移=$scrollOffset');
} else {
AppLogger.d('SelectionToolbar', '📜 滚动控制器状态: 无客户端连接或为null滚动偏移=$scrollOffset');
}
// 步骤4: 获取编辑器在视窗中的位置信息
final editorSize = widget.editorSize;
AppLogger.d('SelectionToolbar', '📝 编辑器尺寸: width=${editorSize.width}, height=${editorSize.height}');
// 步骤5: 计算视窗边界约束
final viewportTop = 0.0;
final viewportBottom = viewportSize.height;
AppLogger.d('SelectionToolbar', '🔲 视窗边界: 顶部=$viewportTop, 底部=$viewportBottom');
// 🚀 使用传入的 targetKey 获取 LayerLink 目标的全局位置
double leaderTopInViewport = 0;
double leaderBottomInViewport = 0;
if (widget.targetKey?.currentContext != null) {
final RenderBox box = widget.targetKey!.currentContext!.findRenderObject() as RenderBox;
final Offset global = box.localToGlobal(Offset.zero);
leaderTopInViewport = global.dy;
leaderBottomInViewport = leaderTopInViewport + box.size.height;
AppLogger.d('SelectionToolbar', '📍 目标全局Y=$leaderTopInViewport');
} else {
// 回退方案使用scrollOffset近似
leaderTopInViewport = scrollOffset;
leaderBottomInViewport = leaderTopInViewport + _lineSpacing;
}
// ================= 新增:根据可用空间决定显示在上方还是下方 =================
// 计算选区上方和下方可用空间
final double spaceAbove = leaderTopInViewport - (_lineSpacing + _toolbarHeight);
final double spaceBelow = viewportBottom - leaderBottomInViewport - (_lineSpacing + _toolbarHeight);
// 默认取传入的showAbove作为初始值
bool shouldShowAbove = widget.showAbove;
// 🚀 新增:检查选中区域是否在前三行,如果是则强制显示在下方
final isInFirstThreeLines = _isSelectionInFirstThreeLines();
AppLogger.d('SelectionToolbar', '前三行检测结果: $isInFirstThreeLines, 原始shouldShowAbove: ${widget.showAbove}');
if (isInFirstThreeLines) {
shouldShowAbove = false;
AppLogger.d('SelectionToolbar', '检测到选中区域在前三行强制显示在下方shouldShowAbove=$shouldShowAbove');
}
// 如果当前方向空间不足,而另一侧空间充足,则自动切换方向
else if (shouldShowAbove && spaceAbove < 0 && spaceBelow > 0) {
shouldShowAbove = false; // 改为显示在下方
AppLogger.d('SelectionToolbar', '空间不足切换到下方显示shouldShowAbove=$shouldShowAbove');
} else if (!shouldShowAbove && spaceBelow < 0 && spaceAbove > 0) {
shouldShowAbove = true; // 改为显示在上方
AppLogger.d('SelectionToolbar', '空间不足切换到上方显示shouldShowAbove=$shouldShowAbove');
}
AppLogger.d('SelectionToolbar', '最终shouldShowAbove决定: $shouldShowAbove (spaceAbove: $spaceAbove, spaceBelow: $spaceBelow)');
// ========================================================================
// 根据最终方向计算 yOffset
double yOffset;
if (shouldShowAbove) {
yOffset = -_toolbarHeight - _lineSpacing;
} else {
// 🚀 对于前三行的情况,使用更大的下方间距
if (isInFirstThreeLines) {
yOffset = _lineSpacing * 30; // 24.0 像素,避免遮挡前三行文本
AppLogger.d('SelectionToolbar', '前三行使用更大下方间距: $yOffset');
} else {
yOffset = _lineSpacing;
}
}
// 边界检查,确保工具栏不会被视口裁剪
final maxUpwardOffset = -leaderTopInViewport + viewportTop + _lineSpacing;
final maxDownwardOffset = viewportBottom - leaderBottomInViewport - _toolbarHeight - _lineSpacing;
yOffset = yOffset.clamp(maxUpwardOffset, maxDownwardOffset).toDouble();
final finalOffset = Offset(0, yOffset);
AppLogger.d('SelectionToolbar', '📐 计算完成最终Offset=$finalOffset (shouldShowAbove=$shouldShowAbove)');
return finalOffset;
} catch (e) {
AppLogger.e('SelectionToolbar', '❌ 计算工具栏位置失败: $e');
// 发生错误时使用默认位置,但也要考虑前三行检测
bool shouldShowAbove = widget.showAbove;
// 🚀 即使在错误恢复时也检查前三行
final isInFirstThreeLines = _isSelectionInFirstThreeLines();
AppLogger.d('SelectionToolbar', '🔧 错误恢复时前三行检测结果: $isInFirstThreeLines, 原始shouldShowAbove: ${widget.showAbove}');
if (isInFirstThreeLines) {
shouldShowAbove = false;
AppLogger.d('SelectionToolbar', '🔧 错误恢复时检测到前三行强制显示在下方shouldShowAbove=$shouldShowAbove');
}
// 🚀 错误恢复时也为前三行使用更大间距
final yOffset = shouldShowAbove ? -60.0 : (isInFirstThreeLines ? 30.0 : 20.0);
final errorOffset = Offset(0, yOffset);
AppLogger.d('SelectionToolbar', '🔧 错误恢复,使用默认位置: $errorOffset (shouldShowAbove=$shouldShowAbove, isInFirstThreeLines=$isInFirstThreeLines)');
return errorOffset;
}
}
@override
void dispose() {
_toolbarFocusNode.dispose();
_removeAiMenuOverlay();
_detachScrollListener(widget.scrollController);
_scrollDebounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _updateToolbarHeight());
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// 🚀 使用智能偏移,保证工具栏始终在视图内
final toolbarOffset = _calculateToolbarOffset();
AppLogger.d('SelectionToolbar', '🎯 使用动态LayerLink跟随offset: $toolbarOffset');
// 构建工具栏,使用 TweenAnimationBuilder 在新旧偏移之间进行平滑插值
final toolbarBody = MouseRegion(
cursor: SystemMouseCursors.click, // 在工具栏上显示手型光标
opaque: true, // 阻止鼠标事件穿透到底层编辑器
hitTestBehavior: HitTestBehavior.opaque, // 确保立即捕获鼠标事件
child: Material(
type: MaterialType.transparency,
child: Container(
constraints: const BoxConstraints(
maxWidth: 600,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:字数统计和撤销重做按钮
_buildTopRow(isDark),
const SizedBox(height: 4),
// 第二行:格式化按钮和功能按钮
_buildBottomRow(isDark),
],
),
),
),
);
// 计算 Tween 的起始值
final Offset tweenBegin = _firstBuild ? toolbarOffset : _lastOffset;
final Offset tweenEnd = toolbarOffset;
// 在本帧结束时记录当前 offset用于下一次动画起点
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastOffset = toolbarOffset;
_firstBuild = false;
});
return TweenAnimationBuilder<Offset>(
tween: Tween<Offset>(begin: tweenBegin, end: tweenEnd),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
builder: (context, animatedOffset, child) {
return CompositedTransformFollower(
link: widget.layerLink,
key: _toolbarKey,
offset: animatedOffset,
followerAnchor: Alignment.bottomCenter, // 工具栏底部中心作为锚点
targetAnchor: Alignment.topCenter, // 目标顶部中心作为锚点
showWhenUnlinked: false,
child: child,
);
},
child: toolbarBody,
);
}
/// 构建顶部行(字数统计和撤销重做)
Widget _buildTopRow(bool isDark) {
return _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 字数统计
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
'${widget.wordCount} Word${widget.wordCount == 1 ? '' : 's'}',
style: TextStyle(
color: isDark ? WebTheme.darkGrey400 : WebTheme.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
// 分隔线
Container(
width: 1,
height: 32,
color: isDark ? WebTheme.darkGrey300 : WebTheme.white,
),
// 撤销重做按钮
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButton(
icon: Icons.undo,
tooltip: '撤销',
isDark: isDark,
isEnabled: widget.controller.hasUndo,
onPressed: () {
if (widget.controller.hasUndo) {
widget.controller.undo();
}
},
),
_buildActionButton(
icon: Icons.redo,
tooltip: '重做',
isDark: isDark,
isEnabled: widget.controller.hasRedo,
onPressed: () {
if (widget.controller.hasRedo) {
widget.controller.redo();
}
},
),
],
),
],
),
);
}
/// 构建底部行(格式化和功能按钮)
Widget _buildBottomRow(bool isDark) {
return LayoutBuilder(
builder: (context, constraints) {
// 计算可用宽度
final availableWidth = constraints.maxWidth;
final buttonGroupsWidth = _estimateButtonGroupsWidth();
// 如果空间不足,使用两行布局
if (buttonGroupsWidth > availableWidth) {
return _buildTwoRowLayout(isDark);
} else {
return _buildSingleRowLayout(isDark);
}
},
);
}
/// 构建单行布局
Widget _buildSingleRowLayout(bool isDark) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 第一行:格式化按钮组 - 使用Flexible包装以防溢出
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 格式化按钮组
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormatButton(
icon: Icons.format_bold,
tooltip: '加粗',
attribute: Attribute.bold,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.format_italic,
tooltip: '斜体',
attribute: Attribute.italic,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.format_underlined,
tooltip: '下划线',
attribute: Attribute.underline,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.strikethrough_s,
tooltip: '删除线',
attribute: Attribute.strikeThrough,
isDark: isDark,
),
_buildTextColorButton(isDark),
_buildHighlightButton(isDark),
],
),
),
),
const SizedBox(width: 4),
// 引用、标题、列表按钮组
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormatButton(
icon: Icons.format_quote,
tooltip: '引用',
attribute: Attribute.blockQuote,
isDark: isDark,
),
_buildDropdownButton(
icon: Icons.title,
tooltip: '标题',
isDark: isDark,
items: [
_DropdownItem('标题 1', () => _applyAttribute(Attribute.h1)),
_DropdownItem('标题 2', () => _applyAttribute(Attribute.h2)),
_DropdownItem('标题 3', () => _applyAttribute(Attribute.h3)),
_DropdownItem('普通文本', () => _clearHeadingAttribute()),
],
),
_buildDropdownButton(
icon: Icons.format_list_numbered,
tooltip: '列表',
isDark: isDark,
items: [
_DropdownItem('无序列表', () => _applyAttribute(Attribute.ul)),
_DropdownItem('有序列表', () => _applyAttribute(Attribute.ol)),
_DropdownItem('检查列表', () => _applyAttribute(Attribute.checked)),
_DropdownItem('移除列表', () => _clearListAttribute()),
],
),
],
),
),
),
const SizedBox(width: 4),
// 功能按钮组(片段、设定、章节)
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
icon: Icons.note_add,
text: '片段',
tooltip: '添加为片段',
isDark: isDark,
onPressed: () => _createSnippetFromSelection(),
),
_buildActionButtonWithText(
icon: Icons.library_books,
text: '设定',
tooltip: '添加为设定',
isDark: isDark,
onPressed: () => _createSettingFromSelection(),
),
_buildActionButtonWithText(
icon: Icons.view_module,
text: '章节',
tooltip: '设置为章节',
isDark: isDark,
onPressed: () {
AppLogger.i('SelectionToolbar', '设置为章节');
},
),
],
),
),
),
],
),
),
),
const SizedBox(height: 4),
// 第二行AI功能按钮 - 使用Flexible包装以防溢出
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 扩写按钮
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
key: _aiButtonKeys['expand'],
icon: Icons.expand_more,
text: '扩写',
tooltip: '扩写选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('expand'),
),
],
),
),
),
const SizedBox(width: 4),
// 重构按钮
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
key: _aiButtonKeys['rewrite'],
icon: Icons.refresh,
text: '重构',
tooltip: '重构选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('rewrite'),
),
],
),
),
),
const SizedBox(width: 4),
// 缩写按钮
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
key: _aiButtonKeys['compress'],
icon: Icons.compress,
text: '缩写',
tooltip: '缩写选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('compress'),
),
],
),
),
),
],
),
),
),
],
);
}
/// 构建两行布局(当空间不足时)
Widget _buildTwoRowLayout(bool isDark) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 第一行:格式化按钮
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 格式化按钮组
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormatButton(
icon: Icons.format_bold,
tooltip: '加粗',
attribute: Attribute.bold,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.format_italic,
tooltip: '斜体',
attribute: Attribute.italic,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.format_underlined,
tooltip: '下划线',
attribute: Attribute.underline,
isDark: isDark,
),
_buildFormatButton(
icon: Icons.strikethrough_s,
tooltip: '删除线',
attribute: Attribute.strikeThrough,
isDark: isDark,
),
_buildTextColorButton(isDark),
_buildHighlightButton(isDark),
],
),
),
),
const SizedBox(width: 4),
// 引用、标题、列表按钮组
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormatButton(
icon: Icons.format_quote,
tooltip: '引用',
attribute: Attribute.blockQuote,
isDark: isDark,
),
_buildDropdownButton(
icon: Icons.title,
tooltip: '标题',
isDark: isDark,
items: [
_DropdownItem('标题 1', () => _applyAttribute(Attribute.h1)),
_DropdownItem('标题 2', () => _applyAttribute(Attribute.h2)),
_DropdownItem('标题 3', () => _applyAttribute(Attribute.h3)),
_DropdownItem('普通文本', () => _clearHeadingAttribute()),
],
),
_buildDropdownButton(
icon: Icons.format_list_numbered,
tooltip: '列表',
isDark: isDark,
items: [
_DropdownItem('无序列表', () => _applyAttribute(Attribute.ul)),
_DropdownItem('有序列表', () => _applyAttribute(Attribute.ol)),
_DropdownItem('检查列表', () => _applyAttribute(Attribute.checked)),
_DropdownItem('移除列表', () => _clearListAttribute()),
],
),
],
),
),
),
],
),
),
),
const SizedBox(height: 4),
// 第二行功能按钮和AI按钮
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 功能按钮组(片段、设定、章节)
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
icon: Icons.note_add,
text: '片段',
tooltip: '添加为片段',
isDark: isDark,
onPressed: () => _createSnippetFromSelection(),
),
_buildActionButtonWithText(
icon: Icons.library_books,
text: '设定',
tooltip: '添加为设定',
isDark: isDark,
onPressed: () => _createSettingFromSelection(),
),
_buildActionButtonWithText(
icon: Icons.view_module,
text: '章节',
tooltip: '设置为章节',
isDark: isDark,
onPressed: () {
AppLogger.i('SelectionToolbar', '设置为章节');
},
),
],
),
),
),
const SizedBox(width: 4),
// AI功能按钮组
Flexible(
child: _buildToolbarContainer(
isDark: isDark,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildActionButtonWithText(
key: _aiButtonKeys['expand'],
icon: Icons.expand_more,
text: '扩写',
tooltip: '扩写选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('expand'),
),
_buildActionButtonWithText(
key: _aiButtonKeys['rewrite'],
icon: Icons.refresh,
text: '重构',
tooltip: '重构选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('rewrite'),
),
_buildActionButtonWithText(
key: _aiButtonKeys['compress'],
icon: Icons.compress,
text: '缩写',
tooltip: '缩写选中内容',
isDark: isDark,
onPressed: () => _showAiMenu('compress'),
),
],
),
),
),
],
),
),
),
],
);
}
/// 估算按钮组总宽度(用于判断是否需要换行)
double _estimateButtonGroupsWidth() {
// 🚀 修改:只估算第一行的宽度(格式化+引用标题列表+功能按钮组)
// 这样可以确保片段和设定始终保持在第一行
// 格式化按钮组: 6个按钮 * 32px ≈ 200px
// 引用标题列表按钮组: 3个按钮 * 32px ≈ 100px
// 功能按钮组: 3个带文本按钮 * 60px ≈ 180px
// 间距: 2个 * 4px = 8px
return 200 + 100 + 180 + 8; // ≈ 488px不包含AI按钮组
}
/// 构建工具栏容器
Widget _buildToolbarContainer({
required bool isDark,
required Widget child,
}) {
return Container(
decoration: BoxDecoration(
// 浅色主题下黑底,深色主题沿用表面色
color: isDark ? WebTheme.getSurfaceColor(context) : WebTheme.black,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: isDark ? 0.1 : 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
child: child,
);
}
/// 构建操作按钮
Widget _buildActionButton({
required IconData icon,
required String tooltip,
required bool isDark,
required VoidCallback onPressed,
bool isEnabled = true,
}) {
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: isEnabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
opaque: true,
child: InkWell(
onTap: isEnabled ? onPressed : null,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
icon,
size: 16,
color: isEnabled
? (isDark ? WebTheme.darkGrey400 : WebTheme.white)
: (isDark ? WebTheme.darkGrey500 : WebTheme.white.withOpacity(0.6)),
),
),
),
),
);
}
/// 构建带文本的操作按钮
Widget _buildActionButtonWithText({
Key? key,
required IconData icon,
required String text,
required String tooltip,
required bool isDark,
required VoidCallback onPressed,
}) {
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: true,
child: InkWell(
key: key,
onTap: onPressed,
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isDark ? const Color(0xFF6B7280) : Colors.white70,
),
const SizedBox(width: 4),
Text(
text,
style: TextStyle(
color: isDark ? WebTheme.darkGrey400 : WebTheme.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
);
}
/// 构建格式按钮
Widget _buildFormatButton({
required IconData icon,
required String tooltip,
required Attribute attribute,
required bool isDark,
}) {
// 检查当前选中文本是否已应用了该属性
final currentStyle = widget.controller.getSelectionStyle();
final bool isActive;
// 对于不同类型的属性,采用不同的判断逻辑
if (attribute.key == 'bold' || attribute.key == 'italic' ||
attribute.key == 'underline' || attribute.key == 'strike') {
// 对于简单的开关型属性判断是否存在且值为true
isActive = currentStyle.attributes.containsKey(attribute.key) &&
currentStyle.attributes[attribute.key]?.value == true;
} else if (attribute.key == 'blockquote') {
// 对于块引用,判断是否存在
isActive = currentStyle.attributes.containsKey(attribute.key);
} else {
// 对于其他属性(如标题),判断是否存在且值匹配
isActive = currentStyle.attributes.containsKey(attribute.key) &&
(currentStyle.attributes[attribute.key]?.value == attribute.value);
}
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: true,
child: InkWell(
onTap: () => _applyAttribute(attribute),
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.all(8),
decoration: isActive ? BoxDecoration(
color: const Color(0xFF3B82F6).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
) : null,
child: Icon(
icon,
size: 16,
color: isActive
? const Color(0xFF3B82F6) // 蓝色激活状态
: (isDark ? const Color(0xFF6B7280) : Colors.white70),
),
),
),
),
);
}
/// 构建文字颜色按钮
Widget _buildTextColorButton(bool isDark) {
// 检查是否设置了文字颜色
final currentStyle = widget.controller.getSelectionStyle();
final bool hasTextColor = currentStyle.attributes.containsKey('color');
return ToolbarMenuButton<Color>(
icon: Icons.text_format,
tooltip: '文字颜色',
isDark: isDark,
isActive: hasTextColor,
items: [
ToolbarMenuItem(
value: Colors.black,
child: const ColorMenuItem(color: Colors.black, label: '黑色'),
),
ToolbarMenuItem(
value: Colors.red,
child: const ColorMenuItem(color: Colors.red, label: '红色'),
),
ToolbarMenuItem(
value: Colors.blue,
child: const ColorMenuItem(color: Colors.blue, label: '蓝色'),
),
ToolbarMenuItem(
value: Colors.green,
child: const ColorMenuItem(color: Colors.green, label: '绿色'),
),
ToolbarMenuItem(
value: Colors.orange,
child: const ColorMenuItem(color: Colors.orange, label: '橙色'),
),
ToolbarMenuItem(
value: Colors.purple,
child: const ColorMenuItem(color: Colors.purple, label: '紫色'),
),
ToolbarMenuItem(
value: Colors.grey,
child: const ColorMenuItem(color: Colors.grey, label: '灰色'),
),
const ToolbarMenuItem.divider(),
ToolbarMenuItem(
value: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.clear, size: 16, color: Colors.black),
const SizedBox(width: 8),
const Text(
'默认颜色',
style: TextStyle(color: Colors.black, fontSize: 14),
),
],
),
),
],
onSelected: (color) {
if (color != null) {
// 将颜色转换为十六进制字符串格式Flutter Quill期望的是这种格式
final hexColor = '#${(color.r * 255).round().toRadixString(16).padLeft(2, '0')}${(color.g * 255).round().toRadixString(16).padLeft(2, '0')}${(color.b * 255).round().toRadixString(16).padLeft(2, '0')}';
_applyAttribute(Attribute('color', AttributeScope.inline, hexColor));
} else {
_applyAttribute(Attribute.clone(const Attribute('color', AttributeScope.inline, null), null));
}
},
);
}
/// 构建背景颜色按钮
Widget _buildHighlightButton(bool isDark) {
// 检查是否设置了背景颜色
final currentStyle = widget.controller.getSelectionStyle();
final bool hasBackgroundColor = currentStyle.attributes.containsKey('background');
return ToolbarMenuButton<Color>(
icon: Icons.palette,
tooltip: '背景颜色',
isDark: isDark,
isActive: hasBackgroundColor,
items: [
ToolbarMenuItem(
value: Colors.red,
child: const ColorMenuItem(color: Colors.red, label: '红色'),
),
ToolbarMenuItem(
value: Colors.orange,
child: const ColorMenuItem(color: Colors.orange, label: '橙色'),
),
ToolbarMenuItem(
value: Colors.yellow,
child: const ColorMenuItem(color: Colors.yellow, label: '黄色'),
),
ToolbarMenuItem(
value: Colors.green,
child: const ColorMenuItem(color: Colors.green, label: '绿色'),
),
ToolbarMenuItem(
value: Colors.blue,
child: const ColorMenuItem(color: Colors.blue, label: '蓝色'),
),
ToolbarMenuItem(
value: Colors.purple,
child: const ColorMenuItem(color: Colors.purple, label: '紫色'),
),
ToolbarMenuItem(
value: Colors.pink,
child: const ColorMenuItem(color: Colors.pink, label: '粉色'),
),
ToolbarMenuItem(
value: Colors.grey,
child: const ColorMenuItem(color: Colors.grey, label: '灰色'),
),
const ToolbarMenuItem.divider(),
ToolbarMenuItem(
value: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.clear, size: 16, color: Colors.black),
const SizedBox(width: 8),
const Text(
'移除颜色',
style: TextStyle(color: Colors.black, fontSize: 14),
),
],
),
),
],
onSelected: (color) {
if (color != null) {
// 将颜色转换为十六进制字符串格式Flutter Quill期望的是这种格式
final hexColor = '#${(color.r * 255).round().toRadixString(16).padLeft(2, '0')}${(color.g * 255).round().toRadixString(16).padLeft(2, '0')}${(color.b * 255).round().toRadixString(16).padLeft(2, '0')}';
_applyAttribute(Attribute('background', AttributeScope.inline, hexColor));
} else {
_applyAttribute(Attribute.clone(const Attribute('background', AttributeScope.inline, null), null));
}
},
);
}
/// 构建下拉按钮
Widget _buildDropdownButton({
required IconData icon,
required String tooltip,
required bool isDark,
required List<_DropdownItem> items,
}) {
// 检查是否有相关属性被激活
final currentStyle = widget.controller.getSelectionStyle();
bool isActive = false;
// 根据tooltip判断是什么类型的按钮
if (tooltip == '标题') {
isActive = currentStyle.attributes.containsKey('header') ||
currentStyle.attributes.containsKey('h1') ||
currentStyle.attributes.containsKey('h2') ||
currentStyle.attributes.containsKey('h3');
} else if (tooltip == '列表') {
isActive = currentStyle.attributes.containsKey('list') ||
currentStyle.attributes.containsKey('ul') ||
currentStyle.attributes.containsKey('ol') ||
currentStyle.attributes.containsKey('checked');
}
final toolbarItems = items.map((item) => ToolbarMenuItem<VoidCallback>(
value: item.onTap,
child: Text(
item.text,
style: const TextStyle(color: Colors.black, fontSize: 14),
),
)).toList();
return ToolbarMenuButton<VoidCallback>(
icon: icon,
tooltip: tooltip,
isDark: isDark,
isActive: isActive,
items: toolbarItems,
onSelected: (callback) => callback?.call(),
);
}
/// 应用文本属性
void _applyAttribute(Attribute attribute) {
try {
// 🚀 关键修复:如果当前选区已折叠,恢复为最初的选区
TextSelection currentSelection = widget.controller.selection;
if (currentSelection.isCollapsed) {
AppLogger.d('SelectionToolbar', '当前选区已折叠,恢复为初始选区');
currentSelection = _initialSelection;
// 恢复选区到编辑器中,避免 Quill 自动收起选区
widget.controller.updateSelection(currentSelection, ChangeSource.local);
}
// 获取选区信息
final int start = currentSelection.start;
final int end = currentSelection.end;
final length = end - start;
// 检查当前选中文本是否已应用了该属性
final currentStyle = widget.controller.getSelectionStyle();
final bool hasAttribute = currentStyle.attributes
.containsKey(attribute.key) &&
(currentStyle.attributes[attribute.key]?.value == attribute.value);
AppLogger.i(
'SelectionToolbar', '当前选区位置: start=$start, end=$end, length=$length');
AppLogger.i('SelectionToolbar',
'当前样式状态: ${attribute.key}=${hasAttribute ? '已应用' : '未应用'}');
AppLogger.d('SelectionToolbar', '当前样式完整内容: ${currentStyle.attributes}');
// 如果已应用该属性,则移除它;否则添加它
if (hasAttribute) {
// 创建一个同名但值为null的属性来移除格式
final nullAttribute = Attribute.clone(attribute, null);
widget.controller.formatText(start, length, nullAttribute);
AppLogger.i('SelectionToolbar', '移除格式: ${attribute.key}');
} else {
// 应用格式
widget.controller.formatText(start, length, attribute);
AppLogger.i(
'SelectionToolbar', '应用格式: ${attribute.key}=${attribute.value}');
}
if (widget.onFormatChanged != null) {
widget.onFormatChanged!();
}
} catch (e, stackTrace) {
AppLogger.e('SelectionToolbar', '应用/移除格式失败', e, stackTrace);
}
}
/// 清除标题属性
void _clearHeadingAttribute() {
try {
// 确保选中文本有效
if (widget.controller.selection.isCollapsed) {
AppLogger.i('SelectionToolbar', '无选中文本,无法清除标题格式');
return;
}
final int start = widget.controller.selection.start;
final int end = widget.controller.selection.end;
final length = end - start;
// 移除所有标题相关属性
for (final attr in [Attribute.h1, Attribute.h2, Attribute.h3]) {
if (widget.controller
.getSelectionStyle()
.attributes
.containsKey(attr.key)) {
widget.controller
.formatText(start, length, Attribute.clone(attr, null));
}
}
AppLogger.i('SelectionToolbar', '清除标题格式');
if (widget.onFormatChanged != null) {
widget.onFormatChanged!();
}
} catch (e, stackTrace) {
AppLogger.e('SelectionToolbar', '清除标题格式失败', e, stackTrace);
}
}
/// 清除列表属性
void _clearListAttribute() {
try {
// 确保选中文本有效
if (widget.controller.selection.isCollapsed) {
AppLogger.i('SelectionToolbar', '无选中文本,无法清除列表格式');
return;
}
final int start = widget.controller.selection.start;
final int end = widget.controller.selection.end;
final length = end - start;
// 移除所有列表相关属性
for (final attr in [Attribute.ul, Attribute.ol, Attribute.checked]) {
if (widget.controller
.getSelectionStyle()
.attributes
.containsKey(attr.key)) {
widget.controller
.formatText(start, length, Attribute.clone(attr, null));
}
}
AppLogger.i('SelectionToolbar', '清除列表格式');
if (widget.onFormatChanged != null) {
widget.onFormatChanged!();
}
} catch (e, stackTrace) {
AppLogger.e('SelectionToolbar', '清除列表格式失败', e, stackTrace);
}
}
/// 获取选中的文本内容
String _getSelectedText() {
try {
final selection = widget.controller.selection;
if (selection.isCollapsed) {
return '';
}
final document = widget.controller.document;
final selectedText = document.getPlainText(
selection.start,
selection.end - selection.start,
);
return selectedText.trim();
} catch (e) {
AppLogger.e('SelectionToolbar', '获取选中文本失败', e);
return '';
}
}
/// 从选中内容创建片段
void _createSnippetFromSelection() {
if (widget.novelId == null) {
AppLogger.w('SelectionToolbar', '缺少novelId无法创建片段');
TopToast.error(context, '无法创建片段:缺少小说信息');
return;
}
final selectedText = _getSelectedText();
if (selectedText.isEmpty) {
AppLogger.w('SelectionToolbar', '无选中文本,无法创建片段');
TopToast.warning(context, '请先选择要添加为片段的文本');
return;
}
AppLogger.i('SelectionToolbar', '创建片段,选中文本: ${selectedText.substring(0, selectedText.length.clamp(0, 50))}...');
// 创建临时片段对象,用于编辑
final tempSnippet = NovelSnippet(
id: '', // 空ID表示新建
userId: '', // 将在保存时由后端填充
novelId: widget.novelId!,
title: '', // 用户在编辑界面填写
content: selectedText, // 预填充选中的内容
metadata: const SnippetMetadata(
wordCount: 0,
characterCount: 0,
viewCount: 0,
sortWeight: 0,
),
isFavorite: false,
status: 'ACTIVE',
version: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
// 显示片段编辑浮动卡片
FloatingSnippetEditor.show(
context: context,
snippet: tempSnippet,
onSaved: (savedSnippet) {
AppLogger.i('SelectionToolbar', '片段创建成功: ${savedSnippet.title}');
widget.onSnippetCreated?.call(savedSnippet);
TopToast.success(context, '片段"${savedSnippet.title}"创建成功');
},
);
}
/// 从选中内容创建设定
void _createSettingFromSelection() {
if (widget.novelId == null) {
AppLogger.w('SelectionToolbar', '缺少novelId无法创建设定');
TopToast.error(context, '无法创建设定:缺少小说信息');
return;
}
final selectedText = _getSelectedText();
if (selectedText.isEmpty) {
AppLogger.w('SelectionToolbar', '无选中文本,无法创建设定');
TopToast.warning(context, '请先选择要添加为设定的文本');
return;
}
AppLogger.i('SelectionToolbar', '创建设定,选中文本: ${selectedText.substring(0, selectedText.length.clamp(0, 50))}...');
// 显示设定编辑浮动卡片
FloatingNovelSettingDetail.show(
context: context,
itemId: null, // null表示新建
novelId: widget.novelId!,
isEditing: true,
prefilledDescription: selectedText, // 预填充选中的文本
onSave: (settingItem, groupId) {
AppLogger.i('SelectionToolbar', '设定创建成功: ${settingItem.name}');
widget.onSettingCreated?.call(settingItem);
TopToast.success(context, '设定"${settingItem.name}"创建成功');
},
onCancel: () {
AppLogger.d('SelectionToolbar', '取消创建设定');
},
);
}
/// 移除AI预设菜单覆盖层
void _removeAiMenuOverlay() {
_aiMenuOverlay?.remove();
_aiMenuOverlay = null;
_currentAiMode = null;
}
/// 显示AI功能菜单
void _showAiMenu(String mode) {
_currentAiMode = mode;
// 获取当前选中的文本
final selectedText = _getSelectedText();
if (selectedText.isEmpty) {
TopToast.warning(context, '请先选择要处理的文本');
return;
}
AppLogger.i('SelectionToolbar', '显示AI预设菜单: $mode, 选中文本: ${selectedText.substring(0, selectedText.length.clamp(0, 50))}...');
// 显示预设快捷菜单
_showPresetQuickMenu(mode, selectedText);
}
/// 显示预设快捷菜单使用MenuAnchor重构版本
void _showPresetQuickMenu(String mode, String selectedText) {
_removeAiMenuOverlay(); // 先清理任何现有菜单
final requestType = _getRequestTypeFromMode(mode);
final buttonKey = _aiButtonKeys[mode];
if (buttonKey?.currentContext == null) {
AppLogger.w('SelectionToolbar', '无法找到按钮context无法显示菜单');
return;
}
final RenderBox buttonBox = buttonKey!.currentContext!.findRenderObject() as RenderBox;
final buttonGlobalPosition = buttonBox.localToGlobal(Offset.zero);
final buttonSize = buttonBox.size;
// 直接在当前位置显示MenuAnchor组件不使用额外的Overlay
final overlayEntry = OverlayEntry(
builder: (context) => Material(
color: Colors.transparent,
child: Stack(
children: [
// 点击空白处关闭菜单
Positioned.fill(
child: GestureDetector(
onTap: _removeAiMenuOverlay,
child: Container(color: Colors.transparent),
),
),
// 菜单本身
Positioned(
left: buttonGlobalPosition.dx,
top: buttonGlobalPosition.dy + buttonSize.height + 4,
child: PresetQuickMenuRefactored(
requestType: requestType,
selectedText: selectedText,
defaultModel: _selectedModel,
onPresetSelected: (preset) {
_removeAiMenuOverlay();
_handlePresetSelection(preset, selectedText);
},
onAdjustAndGenerate: () {
_removeAiMenuOverlay();
_handleAdjustAndGenerate(mode, selectedText);
},
onPresetWithModelSelected: (preset, model) {
_removeAiMenuOverlay();
_handlePresetWithModelSelection(preset, model, selectedText);
},
onStreamingGenerate: (request, model) {
_removeAiMenuOverlay();
_handleStreamingGeneration(request, model);
},
onMenuClosed: _removeAiMenuOverlay,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
),
),
],
),
),
);
_aiMenuOverlay = overlayEntry;
Overlay.of(context).insert(overlayEntry);
}
/// 从模式字符串获取AIRequestType
AIRequestType _getRequestTypeFromMode(String mode) {
return switch (mode) {
'expand' => AIRequestType.expansion,
'rewrite' => AIRequestType.refactor,
'compress' => AIRequestType.summary,
_ => AIRequestType.expansion,
};
}
/// 处理预设选择
void _handlePresetSelection(AIPromptPreset preset, String selectedText) {
AppLogger.i('SelectionToolbar', '选择预设: ${preset.displayName}');
// TODO: 这里需要实现预设应用逻辑
// 1. 从预设中提取模型配置
// 2. 构建UniversalAIRequest
// 3. 启动流式生成
TopToast.info(context, '使用预设"${preset.displayName}"处理文本...');
// 示例构建基本的AI请求
final requestType = _getRequestTypeFromMode(_currentAiMode ?? 'expand');
// 这里需要根据预设内容构建完整的请求
// 暂时使用默认模型进行处理
if (_selectedModel != null) {
final request = UniversalAIRequest(
requestType: requestType,
userId: AppConfig.userId ?? 'current_user', // 从AppConfig获取当前用户ID
novelId: widget.novel?.id,
selectedText: selectedText,
modelConfig: _selectedModel,
prompt: preset.userPrompt,
instructions: preset.systemPrompt,
);
// 将UserAIModelConfigModel包装为PrivateAIModel
final unifiedModel = PrivateAIModel(_selectedModel!);
_handleStreamingGeneration(request, unifiedModel);
} else {
TopToast.warning(context, '请先配置AI模型');
}
}
/// 🚀 处理预设+模型级联选择 - 支持统一AI模型
void _handlePresetWithModelSelection(AIPromptPreset preset, UnifiedAIModel model, String selectedText) {
AppLogger.i('SelectionToolbar', '级联选择: 预设=${preset.displayName}, 模型=${model.displayName} (公共:${model.isPublic})');
// 关闭AI菜单
_removeAiMenuOverlay();
// 构建AI请求
final requestType = _getRequestTypeFromMode(_currentAiMode ?? 'expand');
// 构建模型配置
late UserAIModelConfigModel modelConfig;
if (model.isPublic) {
// 对于公共模型,创建临时的模型配置
final publicModel = (model as PublicAIModel).publicConfig;
modelConfig = UserAIModelConfigModel.fromJson({
'id': 'public_${publicModel.id}',
'userId': AppConfig.userId ?? 'current_user', // 从AppConfig获取当前用户ID
'name': publicModel.displayName,
'alias': publicModel.displayName,
'modelName': publicModel.modelId,
'provider': publicModel.provider,
'apiEndpoint': '', // 公共模型没有单独的apiEndpoint
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
} else {
// 对于私有模型,直接使用用户配置
modelConfig = (model as PrivateAIModel).userConfig;
}
final request = UniversalAIRequest(
requestType: requestType,
userId: AppConfig.userId ?? 'current_user', // 从AppConfig获取当前用户ID
novelId: widget.novel?.id,
selectedText: selectedText,
modelConfig: modelConfig,
prompt: preset.userPrompt,
instructions: preset.systemPrompt,
metadata: {
'action': requestType.name,
'source': 'selection_toolbar',
'presetId': preset.presetId,
'modelName': model.modelId,
'modelProvider': model.provider,
'modelConfigId': model.id,
'isPublicModel': model.isPublic,
if (model.isPublic) 'publicModelConfigId': (model as PublicAIModel).publicConfig.id,
if (model.isPublic) 'publicModelId': (model as PublicAIModel).publicConfig.id,
},
);
// 显示选择信息
TopToast.info(context, '使用"${model.displayName}"运行预设"${preset.displayName}"');
// 启动流式生成
_handleStreamingGeneration(request, model);
}
// 🚀 注释旧的模型选择逻辑已移至PresetQuickMenu组件
// 以下方法已不再需要,因为现在使用预设快捷菜单替代直接的模型选择
/// 处理调整并生成
void _handleAdjustAndGenerate(String mode, String selectedText) {
final modeText = mode == 'expand' ? '扩写' : mode == 'rewrite' ? '重构' : '缩写';
AppLogger.i('SelectionToolbar', '显示${modeText}设置对话框,选中文本: ${selectedText.substring(0, selectedText.length.clamp(0, 50))}...');
// 🚀 获取默认模型配置
UserAIModelConfigModel? modelToUse = _selectedModel;
if (modelToUse == null) {
// 使用BlocBuilder模式获取默认模型
final aiConfigBloc = BlocProvider.of<AiConfigBloc>(context, listen: false);
final aiConfigState = aiConfigBloc.state;
final validatedConfigs = aiConfigState.validatedConfigs;
if (aiConfigState.defaultConfig != null &&
validatedConfigs.any((c) => c.id == aiConfigState.defaultConfig!.id)) {
modelToUse = aiConfigState.defaultConfig;
} else if (validatedConfigs.isNotEmpty) {
modelToUse = validatedConfigs.first;
}
// 更新当前选中模型,避免下次重复查找
_selectedModel = modelToUse;
AppLogger.i('SelectionToolbar', '自动选择默认模型: ${modelToUse?.alias ?? 'null'}');
}
// 添加调试信息
AppLogger.d('SelectionToolbar', '传入数据检查:');
AppLogger.d('SelectionToolbar', '- Novel: ${widget.novel?.title ?? 'null'}');
AppLogger.d('SelectionToolbar', '- Settings: ${widget.settings.length}');
AppLogger.d('SelectionToolbar', '- Setting Groups: ${widget.settingGroups.length}');
AppLogger.d('SelectionToolbar', '- Snippets: ${widget.snippets.length}');
AppLogger.d('SelectionToolbar', '- Selected Model: ${modelToUse?.alias ?? 'null'}');
// 根据模式显示对应的表单对话框
switch (mode) {
case 'expand':
showExpansionDialog(
context,
selectedText: selectedText,
selectedModel: modelToUse,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
onGenerate: () => _handleDirectGeneration(mode, selectedText),
onStreamingGenerate: (request, model) => _handleStreamingGeneration(request, model),
);
break;
case 'rewrite':
showRefactorDialog(
context,
selectedText: selectedText,
selectedModel: modelToUse,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
onGenerate: () => _handleDirectGeneration(mode, selectedText),
onStreamingGenerate: (request, model) => _handleStreamingGeneration(request, model),
);
break;
case 'compress':
showSummaryDialog(
context,
selectedText: selectedText,
selectedModel: modelToUse,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
onGenerate: () => _handleDirectGeneration(mode, selectedText),
onStreamingGenerate: (request, model) => _handleStreamingGeneration(request, model),
);
break;
}
}
/// 处理直接生成(从表单对话框触发)
void _handleDirectGeneration(String mode, String selectedText) {
final modeText = mode == 'expand' ? '扩写' : mode == 'rewrite' ? '重构' : '缩写';
AppLogger.i('SelectionToolbar', '开始AI生成: $modeText, 模型: ${_selectedModel?.alias ?? '未选择'}');
// TODO: 实现实际的AI生成逻辑
TopToast.info(context, '开始${modeText}选中内容...');
}
// 重载方法支持UnifiedAIModel
void _handleStreamingGeneration(UniversalAIRequest request, UnifiedAIModel model) {
AppLogger.i('SelectionToolbar', '启动流式生成: ${request.requestType}, 模型: ${model.displayName} (公共:${model.isPublic})');
// 先通知父组件开始流式生成(⚠️ 必须在隐藏工具栏之前,避免回调丢失)
if (widget.onStreamingGenerationStarted != null) {
widget.onStreamingGenerationStarted!(request, model);
} else {
AppLogger.w('SelectionToolbar', '没有流式生成回调处理器');
// 显示默认消息
TopToast.info(context, '开始流式生成...');
}
// 最后隐藏工具栏
widget.onClosed?.call();
}
}
/// 下拉菜单项数据类
class _DropdownItem {
final String text;
final VoidCallback onTap;
const _DropdownItem(this.text, this.onTap);
}