马良AI写作初始化仓库
This commit is contained in:
520
AINoval/lib/widgets/editor/overlay_scene_beat_manager.dart
Normal file
520
AINoval/lib/widgets/editor/overlay_scene_beat_manager.dart
Normal file
@@ -0,0 +1,520 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/scene_beat_data.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/widgets/editor/overlay_scene_beat_panel.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
// 🚀 新增:导入编辑器状态相关类
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
|
||||
/// 🚀 重构:纯数据管理器 - 只管理数据,不操作UI
|
||||
/// 全局单例,负责场景节拍数据的CRUD操作
|
||||
class SceneBeatDataManager {
|
||||
static SceneBeatDataManager? _instance;
|
||||
static SceneBeatDataManager get instance => _instance ??= SceneBeatDataManager._();
|
||||
|
||||
SceneBeatDataManager._();
|
||||
|
||||
// 🚀 核心:场景节拍数据缓存(场景ID -> 数据)
|
||||
final Map<String, SceneBeatData> _sceneDataCache = {};
|
||||
|
||||
// 🚀 核心:数据变化通知器(场景ID -> 通知器)
|
||||
final Map<String, ValueNotifier<SceneBeatData>> _dataNotifiers = {};
|
||||
|
||||
/// 获取场景数据的通知器(用于UI监听)
|
||||
ValueNotifier<SceneBeatData> getDataNotifier(String sceneId) {
|
||||
return _dataNotifiers.putIfAbsent(sceneId, () {
|
||||
final data = _sceneDataCache[sceneId] ?? SceneBeatData.createDefault(
|
||||
userId: AppConfig.userId ?? 'current-user', // 从AppConfig获取当前用户ID
|
||||
novelId: 'unknown', // TODO: 从场景上下文获取
|
||||
initialPrompt: '为当前场景生成场景节拍',
|
||||
);
|
||||
return ValueNotifier<SceneBeatData>(data);
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取场景数据(纯数据访问,不触发UI)
|
||||
SceneBeatData getSceneData(String sceneId) {
|
||||
final data = _sceneDataCache[sceneId];
|
||||
if (data != null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 创建默认数据但不立即缓存
|
||||
return SceneBeatData.createDefault(
|
||||
userId: AppConfig.userId ?? 'current-user', // 从AppConfig获取当前用户ID
|
||||
novelId: 'unknown',
|
||||
initialPrompt: '为当前场景生成场景节拍',
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新场景数据(纯数据操作)
|
||||
void updateSceneData(String sceneId, SceneBeatData newData) {
|
||||
// 🚀 优化:检查数据是否真正发生变化
|
||||
final currentData = _sceneDataCache[sceneId];
|
||||
if (currentData != null && _isDataEqual(currentData, newData)) {
|
||||
AppLogger.v('SceneBeatDataManager', '📊 场景数据无变化,跳过更新: $sceneId');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('SceneBeatDataManager', '🔄 更新场景数据: $sceneId');
|
||||
|
||||
// 更新缓存
|
||||
_sceneDataCache[sceneId] = newData;
|
||||
|
||||
// 通知UI(如果有监听器的话)
|
||||
final notifier = _dataNotifiers[sceneId];
|
||||
if (notifier != null) {
|
||||
notifier.value = newData;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 判断两个SceneBeatData是否相等(基于关键字段)
|
||||
bool _isDataEqual(SceneBeatData data1, SceneBeatData data2) {
|
||||
return data1.requestData == data2.requestData &&
|
||||
data1.generatedContentDelta == data2.generatedContentDelta &&
|
||||
data1.selectedUnifiedModelId == data2.selectedUnifiedModelId &&
|
||||
data1.selectedLength == data2.selectedLength &&
|
||||
data1.temperature == data2.temperature &&
|
||||
data1.topP == data2.topP &&
|
||||
data1.enableSmartContext == data2.enableSmartContext &&
|
||||
data1.contextSelectionsData == data2.contextSelectionsData &&
|
||||
data1.status == data2.status &&
|
||||
data1.progress == data2.progress;
|
||||
}
|
||||
|
||||
/// 🚀 公开方法:判断两个SceneBeatData是否相等
|
||||
bool isDataEqual(SceneBeatData data1, SceneBeatData data2) {
|
||||
return _isDataEqual(data1, data2);
|
||||
}
|
||||
|
||||
/// 更新场景状态(便捷方法)
|
||||
void updateSceneStatus(String sceneId, SceneBeatStatus status) {
|
||||
final currentData = getSceneData(sceneId);
|
||||
final updatedData = currentData.updateStatus(status);
|
||||
updateSceneData(sceneId, updatedData);
|
||||
}
|
||||
|
||||
/// 清理场景数据
|
||||
void clearSceneData(String sceneId) {
|
||||
AppLogger.i('SceneBeatDataManager', '🗑️ 清理场景数据: $sceneId');
|
||||
_sceneDataCache.remove(sceneId);
|
||||
|
||||
final notifier = _dataNotifiers.remove(sceneId);
|
||||
notifier?.dispose();
|
||||
}
|
||||
|
||||
/// 清理所有数据
|
||||
void clearAllData() {
|
||||
AppLogger.i('SceneBeatDataManager', '🗑️ 清理所有场景节拍数据');
|
||||
_sceneDataCache.clear();
|
||||
|
||||
for (final notifier in _dataNotifiers.values) {
|
||||
notifier.dispose();
|
||||
}
|
||||
_dataNotifiers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 重构:UI管理器 - 只管理UI显示/隐藏,不处理数据
|
||||
/// 全局单例,负责浮动面板的显示状态管理
|
||||
class OverlaySceneBeatManager {
|
||||
static OverlaySceneBeatManager? _instance;
|
||||
static OverlaySceneBeatManager get instance => _instance ??= OverlaySceneBeatManager._();
|
||||
|
||||
OverlaySceneBeatManager._();
|
||||
|
||||
// 🚀 UI状态:当前显示的浮动面板
|
||||
OverlayEntry? _currentOverlay;
|
||||
|
||||
// 🚀 UI状态:当前场景ID(UI层面的概念)
|
||||
final ValueNotifier<String?> _currentSceneIdNotifier = ValueNotifier<String?>(null);
|
||||
|
||||
// 🚀 UI状态:显示状态
|
||||
bool _isVisible = false;
|
||||
|
||||
// 🚀 UI参数缓存(避免重复传递)
|
||||
Novel? _cachedNovel;
|
||||
List<NovelSettingItem> _cachedSettings = [];
|
||||
List<SettingGroup> _cachedSettingGroups = [];
|
||||
List<NovelSnippet> _cachedSnippets = [];
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? _cachedOnGenerate;
|
||||
|
||||
// 🚀 新增:编辑器状态监听
|
||||
EditorScreenController? _editorController;
|
||||
EditorLayoutManager? _layoutManager;
|
||||
VoidCallback? _editorControllerListener;
|
||||
VoidCallback? _layoutManagerListener;
|
||||
|
||||
/// 获取当前场景ID通知器(UI监听用)
|
||||
ValueNotifier<String?> get currentSceneIdNotifier => _currentSceneIdNotifier;
|
||||
|
||||
/// 获取当前场景ID
|
||||
String? get currentSceneId => _currentSceneIdNotifier.value;
|
||||
|
||||
/// 是否显示中
|
||||
bool get isVisible => _isVisible;
|
||||
|
||||
/// 🚀 新增:绑定编辑器状态监听
|
||||
void bindEditorState({
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🔗 绑定编辑器状态监听');
|
||||
|
||||
// 清理之前的监听器
|
||||
unbindEditorState();
|
||||
|
||||
_editorController = editorController;
|
||||
_layoutManager = layoutManager;
|
||||
|
||||
// 监听编辑器状态变化
|
||||
if (_editorController != null) {
|
||||
_editorControllerListener = () {
|
||||
_onEditorStateChanged();
|
||||
};
|
||||
_editorController!.addListener(_editorControllerListener!);
|
||||
}
|
||||
|
||||
// 监听布局管理器状态变化
|
||||
if (_layoutManager != null) {
|
||||
_layoutManagerListener = () {
|
||||
_onLayoutStateChanged();
|
||||
};
|
||||
_layoutManager!.addListener(_layoutManagerListener!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:解绑编辑器状态监听
|
||||
void unbindEditorState() {
|
||||
if (_editorController != null && _editorControllerListener != null) {
|
||||
_editorController!.removeListener(_editorControllerListener!);
|
||||
_editorController = null;
|
||||
_editorControllerListener = null;
|
||||
}
|
||||
|
||||
if (_layoutManager != null && _layoutManagerListener != null) {
|
||||
_layoutManager!.removeListener(_layoutManagerListener!);
|
||||
_layoutManager = null;
|
||||
_layoutManagerListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:处理编辑器状态变化
|
||||
void _onEditorStateChanged() {
|
||||
if (_editorController == null || !_isVisible) return;
|
||||
|
||||
// 检查是否切换到了其他视图
|
||||
final bool isInMainEditMode = !_editorController!.isPlanViewActive &&
|
||||
!_editorController!.isNextOutlineViewActive &&
|
||||
!_editorController!.isPromptViewActive;
|
||||
|
||||
if (!isInMainEditMode) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '📺 检测到视图切换,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:处理布局状态变化
|
||||
void _onLayoutStateChanged() {
|
||||
if (_layoutManager == null || !_isVisible) return;
|
||||
|
||||
// 检查是否有设置面板显示
|
||||
if (_layoutManager!.isSettingsPanelVisible) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '⚙️ 检测到设置面板显示,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
|
||||
// 检查是否有其他重要对话框显示
|
||||
if (_layoutManager!.isNovelSettingsVisible) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '📖 检测到小说设置显示,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 显示浮动面板(只处理UI显示,不管理数据)
|
||||
void show({
|
||||
required BuildContext context,
|
||||
required String sceneId,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate,
|
||||
// 🚀 新增:可选的编辑器状态参数
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🎯 显示场景节拍面板: $sceneId');
|
||||
|
||||
// 🚀 绑定编辑器状态监听
|
||||
bindEditorState(
|
||||
editorController: editorController,
|
||||
layoutManager: layoutManager,
|
||||
);
|
||||
|
||||
// 🚀 检查当前是否在主编辑模式
|
||||
if (editorController != null) {
|
||||
final bool isInMainEditMode = !editorController.isPlanViewActive &&
|
||||
!editorController.isNextOutlineViewActive &&
|
||||
!editorController.isPromptViewActive;
|
||||
|
||||
if (!isInMainEditMode) {
|
||||
AppLogger.w('OverlaySceneBeatManager', '⚠️ 当前不在主编辑模式,跳过显示场景节拍面板');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 检查是否有设置面板显示
|
||||
if (layoutManager != null && layoutManager.isSettingsPanelVisible) {
|
||||
AppLogger.w('OverlaySceneBeatManager', '⚠️ 设置面板正在显示,跳过显示场景节拍面板');
|
||||
return;
|
||||
}
|
||||
|
||||
// 缓存参数
|
||||
_cachedNovel = novel;
|
||||
_cachedSettings = settings;
|
||||
_cachedSettingGroups = settingGroups;
|
||||
_cachedSnippets = snippets;
|
||||
_cachedOnGenerate = onGenerate;
|
||||
|
||||
// 如果已经显示,只切换场景
|
||||
if (_isVisible && _currentOverlay != null) {
|
||||
switchScene(sceneId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的浮动面板
|
||||
_currentOverlay = _createOverlayEntry(context, sceneId);
|
||||
|
||||
// 插入到Overlay中
|
||||
Overlay.of(context).insert(_currentOverlay!);
|
||||
|
||||
// 更新状态
|
||||
_isVisible = true;
|
||||
_currentSceneIdNotifier.value = sceneId;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ 场景节拍面板已显示');
|
||||
}
|
||||
|
||||
/// 🚀 切换场景(只更新场景ID,面板自动响应)
|
||||
void switchScene(String sceneId) {
|
||||
if (_currentSceneIdNotifier.value == sceneId) {
|
||||
AppLogger.v('OverlaySceneBeatManager', '场景ID相同,跳过切换: $sceneId');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '🔄 切换场景: ${_currentSceneIdNotifier.value} -> $sceneId');
|
||||
|
||||
// 只更新场景ID,UI会自动响应
|
||||
_currentSceneIdNotifier.value = sceneId;
|
||||
}
|
||||
|
||||
/// 🚀 隐藏面板(只处理UI隐藏)
|
||||
void hide() {
|
||||
if (!_isVisible || _currentOverlay == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '🫥 隐藏场景节拍面板');
|
||||
|
||||
// 移除浮动面板
|
||||
_currentOverlay!.remove();
|
||||
_currentOverlay = null;
|
||||
|
||||
// 更新状态
|
||||
_isVisible = false;
|
||||
_currentSceneIdNotifier.value = null;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ 场景节拍面板已隐藏');
|
||||
}
|
||||
|
||||
/// 🚀 切换显示状态
|
||||
void toggle({
|
||||
required BuildContext context,
|
||||
required String sceneId,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate,
|
||||
// 🚀 新增:可选的编辑器状态参数
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
if (_isVisible) {
|
||||
hide();
|
||||
} else {
|
||||
show(
|
||||
context: context,
|
||||
sceneId: sceneId,
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
onGenerate: onGenerate,
|
||||
editorController: editorController,
|
||||
layoutManager: layoutManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 创建浮动面板UI(新架构:UI独立管理)
|
||||
OverlayEntry _createOverlayEntry(BuildContext context, String initialSceneId) {
|
||||
return OverlayEntry(
|
||||
builder: (overlayContext) => ValueListenableBuilder<String?>(
|
||||
valueListenable: _currentSceneIdNotifier,
|
||||
builder: (context, currentSceneId, child) {
|
||||
if (currentSceneId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SceneBeatFloatingPanel(
|
||||
sceneId: currentSceneId,
|
||||
novel: _cachedNovel,
|
||||
settings: _cachedSettings,
|
||||
settingGroups: _cachedSettingGroups,
|
||||
snippets: _cachedSnippets,
|
||||
onClose: hide,
|
||||
onGenerate: _cachedOnGenerate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 修改:增强的释放资源方法
|
||||
void dispose() {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🗑️ 开始释放UI管理器资源');
|
||||
|
||||
// 隐藏面板
|
||||
hide();
|
||||
|
||||
// 解绑编辑器状态监听
|
||||
unbindEditorState();
|
||||
|
||||
// 释放通知器
|
||||
_currentSceneIdNotifier.dispose();
|
||||
|
||||
// 清理缓存
|
||||
_cachedNovel = null;
|
||||
_cachedSettings = [];
|
||||
_cachedSettingGroups = [];
|
||||
_cachedSnippets = [];
|
||||
_cachedOnGenerate = null;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ UI管理器资源已释放');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:场景节拍浮动面板UI组件
|
||||
/// 职责:纯UI展示,通过监听数据管理器获取数据变化
|
||||
class SceneBeatFloatingPanel extends StatefulWidget {
|
||||
const SceneBeatFloatingPanel({
|
||||
super.key,
|
||||
required this.sceneId,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.onClose,
|
||||
this.onGenerate,
|
||||
});
|
||||
|
||||
final String sceneId;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final VoidCallback? onClose;
|
||||
final Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate;
|
||||
|
||||
@override
|
||||
State<SceneBeatFloatingPanel> createState() => _SceneBeatFloatingPanelState();
|
||||
}
|
||||
|
||||
class _SceneBeatFloatingPanelState extends State<SceneBeatFloatingPanel> {
|
||||
// 🚀 数据监听器(只监听当前场景的数据变化)
|
||||
late ValueNotifier<SceneBeatData> _dataNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupDataListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SceneBeatFloatingPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 🚀 只有场景ID变化时才重新设置监听器
|
||||
if (oldWidget.sceneId != widget.sceneId) {
|
||||
AppLogger.i('SceneBeatFloatingPanel', '🔄 场景切换,重新设置数据监听: ${oldWidget.sceneId} -> ${widget.sceneId}');
|
||||
_setupDataListener();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 设置数据监听器(核心:数据和UI分离)
|
||||
void _setupDataListener() {
|
||||
// 获取当前场景的数据通知器
|
||||
_dataNotifier = SceneBeatDataManager.instance.getDataNotifier(widget.sceneId);
|
||||
|
||||
AppLogger.i('SceneBeatFloatingPanel', '📡 设置场景数据监听: ${widget.sceneId}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🚀 核心:优化重建策略,减少不必要的重建
|
||||
return ValueListenableBuilder<SceneBeatData>(
|
||||
valueListenable: _dataNotifier,
|
||||
// 🚀 使用 child 参数缓存不需要重建的部分
|
||||
child: _buildStaticContent(),
|
||||
builder: (context, sceneBeatData, child) {
|
||||
// 🚀 直接返回面板,避免ParentData冲突
|
||||
return OverlaySceneBeatPanel(
|
||||
sceneId: widget.sceneId,
|
||||
data: sceneBeatData,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
onClose: widget.onClose,
|
||||
onGenerate: widget.onGenerate != null
|
||||
? (request, model) => widget.onGenerate!(widget.sceneId, request, model)
|
||||
: null,
|
||||
onDataChanged: (newData) {
|
||||
// 🚀 避免无谓的更新:只在数据真正改变时才更新
|
||||
if (_shouldUpdateData(sceneBeatData, newData)) {
|
||||
SceneBeatDataManager.instance.updateSceneData(widget.sceneId, newData);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 构建静态内容(不需要监听数据变化的部分)
|
||||
Widget _buildStaticContent() {
|
||||
// 这里可以放置不依赖于数据的静态组件
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// 🚀 判断是否需要更新数据(避免无意义的更新)
|
||||
bool _shouldUpdateData(SceneBeatData oldData, SceneBeatData newData) {
|
||||
// 🚀 简化:利用数据管理器的公开相等性检查方法
|
||||
return !SceneBeatDataManager.instance.isDataEqual(oldData, newData);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 🚀 不需要手动dispose _dataNotifier,由数据管理器统一管理
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
1762
AINoval/lib/widgets/editor/overlay_scene_beat_panel.dart
Normal file
1762
AINoval/lib/widgets/editor/overlay_scene_beat_panel.dart
Normal file
File diff suppressed because it is too large
Load Diff
323
AINoval/lib/widgets/editor/slash_command_menu.dart
Normal file
323
AINoval/lib/widgets/editor/slash_command_menu.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 斜杠命令类型
|
||||
enum SlashCommandType {
|
||||
sceneBeat,
|
||||
continue_,
|
||||
summary,
|
||||
refactor,
|
||||
dialogue,
|
||||
sceneDescription;
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return '场景节拍';
|
||||
case SlashCommandType.continue_:
|
||||
return '续写';
|
||||
case SlashCommandType.summary:
|
||||
return '摘要';
|
||||
case SlashCommandType.refactor:
|
||||
return '重构';
|
||||
case SlashCommandType.dialogue:
|
||||
return '对话';
|
||||
case SlashCommandType.sceneDescription:
|
||||
return '描述';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return Icons.waves_outlined;
|
||||
case SlashCommandType.continue_:
|
||||
return Icons.edit_outlined;
|
||||
case SlashCommandType.summary:
|
||||
return Icons.summarize_outlined;
|
||||
case SlashCommandType.refactor:
|
||||
return Icons.transform_outlined;
|
||||
case SlashCommandType.dialogue:
|
||||
return Icons.chat_bubble_outline;
|
||||
case SlashCommandType.sceneDescription:
|
||||
return Icons.landscape_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
String get desc {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return '一个关键时刻,重要的事情发生改变,推动故事发展';
|
||||
case SlashCommandType.continue_:
|
||||
return '基于当前上下文继续创作内容';
|
||||
case SlashCommandType.summary:
|
||||
return '生成当前内容的摘要';
|
||||
case SlashCommandType.refactor:
|
||||
return '重新整理和优化现有内容';
|
||||
case SlashCommandType.dialogue:
|
||||
return '生成角色之间的对话';
|
||||
case SlashCommandType.sceneDescription:
|
||||
return '添加场景或人物的详细描述';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 斜杠命令菜单组件
|
||||
class SlashCommandMenu extends StatefulWidget {
|
||||
const SlashCommandMenu({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.onCommandSelected,
|
||||
this.onDismiss,
|
||||
this.availableCommands = SlashCommandType.values,
|
||||
this.maxWidth = 280,
|
||||
});
|
||||
|
||||
/// 菜单显示位置
|
||||
final Offset position;
|
||||
|
||||
/// 命令被选中时的回调
|
||||
final Function(SlashCommandType) onCommandSelected;
|
||||
|
||||
/// 菜单被取消时的回调
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
/// 可用的命令列表
|
||||
final List<SlashCommandType> availableCommands;
|
||||
|
||||
/// 菜单最大宽度
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
State<SlashCommandMenu> createState() => _SlashCommandMenuState();
|
||||
}
|
||||
|
||||
class _SlashCommandMenuState extends State<SlashCommandMenu>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
));
|
||||
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _selectCommand(SlashCommandType command) {
|
||||
AppLogger.d('SlashCommandMenu', '选择命令: ${command.displayName}');
|
||||
widget.onCommandSelected(command);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _opacityAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxWidth,
|
||||
maxHeight: 400,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI 写作助手',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outline.withOpacity(0.1),
|
||||
),
|
||||
|
||||
// 命令列表
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.availableCommands.length,
|
||||
itemBuilder: (context, index) {
|
||||
final command = widget.availableCommands[index];
|
||||
final isSelected = index == _selectedIndex;
|
||||
|
||||
return _buildCommandItem(
|
||||
theme,
|
||||
command,
|
||||
isSelected,
|
||||
() => _selectCommand(command),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 提示文字
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Text(
|
||||
'使用 ↑↓ 选择,Enter 确认,Esc 取消',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandItem(
|
||||
ThemeData theme,
|
||||
SlashCommandType command,
|
||||
bool isSelected,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onHover: (hovering) {
|
||||
if (hovering) {
|
||||
setState(() {
|
||||
_selectedIndex = widget.availableCommands.indexOf(command);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
command.icon,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
command.displayName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
command.desc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
AINoval/lib/widgets/editor/slash_command_overlay.dart
Normal file
47
AINoval/lib/widgets/editor/slash_command_overlay.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/widgets/editor/slash_command_menu.dart';
|
||||
|
||||
/// 斜杠命令覆盖层
|
||||
/// 用于在编辑器上显示命令选择菜单
|
||||
class SlashCommandOverlay {
|
||||
static OverlayEntry? _overlayEntry;
|
||||
|
||||
/// 显示斜杠命令菜单
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required Offset position,
|
||||
required Function(SlashCommandType) onCommandSelected,
|
||||
required VoidCallback onDismiss,
|
||||
required List<SlashCommandType> availableCommands,
|
||||
}) {
|
||||
// 如果已经显示了菜单,先隐藏
|
||||
hide();
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SlashCommandMenu(
|
||||
position: position,
|
||||
onCommandSelected: onCommandSelected,
|
||||
onDismiss: onDismiss,
|
||||
availableCommands: availableCommands,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
/// 隐藏斜杠命令菜单
|
||||
static void hide() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
/// 检查是否正在显示菜单
|
||||
static bool get isShowing => _overlayEntry != null;
|
||||
}
|
||||
Reference in New Issue
Block a user