Browse Source

完成文本编辑器的剥离

master
hejl 2 months ago
parent
commit
afbfe97588
  1. 4
      win_text_editor/lib/app/app.dart
  2. 4
      win_text_editor/lib/app/menus/menu_actions.dart
  3. 4
      win_text_editor/lib/app/models/tab_model.dart
  4. 20
      win_text_editor/lib/app/providers/tab_provider.dart
  5. 4
      win_text_editor/lib/app/widgets/editor_pane.dart
  6. 4
      win_text_editor/lib/app/widgets/file_explorer.dart
  7. 301
      win_text_editor/lib/app/widgets/template_parser_tab.dart
  8. 313
      win_text_editor/lib/app/widgets/text_editor.dart

4
win_text_editor/lib/app/app.dart

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:win_text_editor/app/menus/app_menu.dart';
import 'package:win_text_editor/app/providers/editor_provider.dart';
import 'package:win_text_editor/app/providers/tab_provider.dart';
import 'package:win_text_editor/app/providers/file_provider.dart';
import 'package:win_text_editor/app/widgets/editor_pane.dart';
import 'package:win_text_editor/app/widgets/file_explorer.dart';
@ -15,7 +15,7 @@ class AppScaffold extends StatelessWidget { @@ -15,7 +15,7 @@ class AppScaffold extends StatelessWidget {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => FileProvider()),
ChangeNotifierProvider(create: (_) => EditorProvider()),
ChangeNotifierProvider(create: (_) => TabProvider()),
],
child: const Scaffold(
body: Column(

4
win_text_editor/lib/app/menus/menu_actions.dart

@ -3,7 +3,7 @@ import 'package:file_picker/file_picker.dart'; @@ -3,7 +3,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:provider/provider.dart';
import 'package:win_text_editor/app/menus/menu_constants.dart';
import 'package:win_text_editor/app/providers/file_provider.dart';
import 'package:win_text_editor/app/providers/editor_provider.dart';
import 'package:win_text_editor/app/providers/tab_provider.dart';
import 'package:collection/collection.dart';
import 'dart:io';
@ -39,7 +39,7 @@ class MenuActions { @@ -39,7 +39,7 @@ class MenuActions {
}
static Future<void> _openTemplateParser(BuildContext context) async {
final editorProvider = Provider.of<EditorProvider>(context, listen: false);
final editorProvider = Provider.of<TabProvider>(context, listen: false);
// 使 firstWhereOrNull
final existingTab = editorProvider.tabs.firstWhereOrNull(

4
win_text_editor/lib/app/models/tab_model.dart

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
class EditorTab {
class ContentTab {
final String id;
final String title;
final String? type; //
@ -8,7 +8,7 @@ class EditorTab { @@ -8,7 +8,7 @@ class EditorTab {
String content;
String? fileName;
EditorTab({
ContentTab({
required this.id,
required this.title,
this.type,

20
win_text_editor/lib/app/providers/editor_provider.dart → win_text_editor/lib/app/providers/tab_provider.dart

@ -3,11 +3,11 @@ import 'package:win_text_editor/app/models/tab_model.dart'; @@ -3,11 +3,11 @@ import 'package:win_text_editor/app/models/tab_model.dart';
import 'package:win_text_editor/app/providers/logger.dart';
import 'package:win_text_editor/app/widgets/template_parser_tab.dart';
class EditorProvider with ChangeNotifier {
final List<EditorTab> _tabs = [];
class TabProvider with ChangeNotifier {
final List<ContentTab> _tabs = [];
String? _activeTabId;
List<EditorTab> get tabs => _tabs;
List<ContentTab> get tabs => _tabs;
String? get activeTabId => _activeTabId;
final Map<String, TemplateParserTabState> _tabControllers = {};
@ -20,7 +20,7 @@ class EditorProvider with ChangeNotifier { @@ -20,7 +20,7 @@ class EditorProvider with ChangeNotifier {
_tabControllers.remove(tabId);
}
EditorTab? get activeTab {
ContentTab? get activeTab {
if (_activeTabId == null) return null;
try {
return _tabs.firstWhere((tab) => tab.id == _activeTabId);
@ -36,7 +36,7 @@ class EditorProvider with ChangeNotifier { @@ -36,7 +36,7 @@ class EditorProvider with ChangeNotifier {
IconData? icon,
String content = '',
}) async {
final newTab = EditorTab(
final newTab = ContentTab(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
type: type,
@ -49,7 +49,7 @@ class EditorProvider with ChangeNotifier { @@ -49,7 +49,7 @@ class EditorProvider with ChangeNotifier {
notifyListeners();
}
EditorTab? getTabById(String tabId) {
ContentTab? getTabById(String tabId) {
try {
return _tabs.firstWhere((tab) => tab.id == tabId);
} catch (e) {
@ -94,19 +94,19 @@ class EditorProvider with ChangeNotifier { @@ -94,19 +94,19 @@ class EditorProvider with ChangeNotifier {
return;
}
final textTabState = _tabControllers[_activeTabId];
if (textTabState == null) {
final parserTabState = _tabControllers[_activeTabId];
if (parserTabState == null) {
Logger().warning("找不到 TextTab 状态");
return;
}
if (!textTabState.mounted) {
if (!parserTabState.mounted) {
Logger().warning("TextTab 状态组件未挂载");
return;
}
try {
await textTabState.loadFile(context, filePath);
await parserTabState.loadFile(context, filePath);
} catch (e) {
Logger().error("加载文件失败: ${e.toString()}");
if (context.mounted) {

4
win_text_editor/lib/app/widgets/editor_pane.dart

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:win_text_editor/app/providers/editor_provider.dart';
import 'package:win_text_editor/app/providers/tab_provider.dart';
import 'template_parser_tab.dart';
class EditorPane extends StatelessWidget {
@ -8,7 +8,7 @@ class EditorPane extends StatelessWidget { @@ -8,7 +8,7 @@ class EditorPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = Provider.of<EditorProvider>(context);
final provider = Provider.of<TabProvider>(context);
return Column(
children: [

4
win_text_editor/lib/app/widgets/file_explorer.dart

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:win_text_editor/app/providers/editor_provider.dart';
import 'package:win_text_editor/app/providers/tab_provider.dart';
import 'package:win_text_editor/app/providers/logger.dart';
import '../models/file_node.dart';
@ -62,7 +62,7 @@ class _FileExplorerState extends State<FileExplorer> { @@ -62,7 +62,7 @@ class _FileExplorerState extends State<FileExplorer> {
Future<void> _openFileInEditor(BuildContext context, FileNode node) async {
if (!node.isDirectory) {
try {
final editorProvider = Provider.of<EditorProvider>(context, listen: false);
final editorProvider = Provider.of<TabProvider>(context, listen: false);
await editorProvider.requestLoadFile(context, node.path);
} catch (e) {
Logger().error("打开文件失败: ${e.toString()}");

301
win_text_editor/lib/app/widgets/template_parser_tab.dart

@ -1,12 +1,7 @@ @@ -1,12 +1,7 @@
import 'dart:convert';
import 'dart:ui';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import 'package:win_text_editor/app/providers/editor_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:win_text_editor/app/providers/tab_provider.dart';
import 'package:win_text_editor/app/widgets/text_editor.dart';
class TemplateParserTab extends StatefulWidget {
final String tabId;
@ -18,298 +13,34 @@ class TemplateParserTab extends StatefulWidget { @@ -18,298 +13,34 @@ class TemplateParserTab extends StatefulWidget {
}
class TemplateParserTabState extends State<TemplateParserTab> {
late TextEditingController _controller;
late EditorProvider _provider;
late FocusNode _focusNode;
late ScrollController _scrollController;
bool _isLoading = false;
static const int maxFileSize = 1024 * 1024; // 1MB
late TabProvider _provider;
@override
void initState() {
super.initState();
_provider = Provider.of<EditorProvider>(context, listen: false);
_provider = Provider.of<TabProvider>(context, listen: false);
_provider.registerTextTabController(widget.tabId, this);
_controller = TextEditingController(text: _getCurrentContent());
_focusNode = FocusNode();
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(_focusNode);
});
}
String _getCurrentContent() {
return _provider.tabs.firstWhere((t) => t.id == widget.tabId).content;
}
@override
void didUpdateWidget(TemplateParserTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.tabId != widget.tabId) {
_controller.text = _getCurrentContent();
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> loadFile(BuildContext context, String filePath) async {}
@override
Widget build(BuildContext context) {
final tab = _provider.tabs.firstWhereOrNull((t) => t.id == widget.tabId);
final tab = _provider.tabs.firstWhere((t) => t.id == widget.tabId);
if (tab == null) {
return const Center(child: Text('选项卡不存在'));
}
String fileNameText =
tab.fileName != null && tab.fileName!.isNotEmpty ? '${tab.fileName},' : '';
return Column(
children: [
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'源文本${tab.content.isEmpty ? '' : ' ($fileNameText${tab.content.length}字符)'}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.folder_open, size: 20),
tooltip: '打开文件',
onPressed: _isLoading ? null : () => _openFile(context),
),
IconButton(
icon: const Icon(Icons.content_copy, size: 20),
tooltip: '复制内容',
onPressed:
tab.content.isEmpty ? null : () => _copyToClipboard(context, tab.content),
),
IconButton(
icon: const Icon(Icons.save, size: 20),
tooltip: '保存到文件',
onPressed: tab.content.isEmpty ? null : () => _saveFile(context, tab.content),
),
if (_isLoading)
const Padding(
padding: EdgeInsets.only(left: 8),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
],
),
),
Expanded(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: true,
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: SingleChildScrollView(
controller: _scrollController,
child: Stack(
children: [
TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
onChanged: (text) => _provider.updateContent(widget.tabId, text, tab.fileName),
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
],
),
),
),
),
],
return TextEditor(
tabId: widget.tabId,
initialContent: tab.content,
fileName: tab.fileName,
onContentChanged: (content, fileName) {
_provider.updateContent(widget.tabId, content, fileName);
},
onFileLoaded: (filePath) {
//
},
);
}
Future<void> _openFile(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false);
if (result != null && result.files.single.path != null) {
await loadFile(context, result.files.single.path!);
}
}
Future<void> _copyToClipboard(BuildContext context, String content) async {
await Clipboard.setData(ClipboardData(text: content));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制到剪贴板')));
}
}
Future<void> _saveFile(BuildContext context, String content) async {
try {
String? outputPath = await FilePicker.platform.saveFile(
dialogTitle: '保存文件',
fileName: 'untitled.txt',
allowedExtensions: ['txt'],
type: FileType.any,
);
if (outputPath == null) return;
final file = File(outputPath);
if (await file.exists()) {
final shouldOverwrite = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('文件已存在'),
content: const Text('要覆盖现有文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('覆盖'),
),
],
),
);
if (shouldOverwrite != true) return;
}
await file.writeAsString(content);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存到: ${file.path}')));
}
} on FileSystemException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.message}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}')));
}
}
}
//
Future<void> loadFile(BuildContext context, String filePath) async {
try {
setState(() => _isLoading = true);
final file = File(filePath);
final fileSize = await file.length();
//
if (fileSize > maxFileSize) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('文件过大(超过1MB),无法处理')));
}
return;
}
//
final activeTab = _provider.getTabById(widget.tabId);
if (activeTab != null && activeTab.content.isNotEmpty) {
final confirm = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('确认提示'),
content: const Text('确认是否打开新的文件?打开后将覆盖当前内容'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirm != true) {
return;
}
}
final _fileName = file.path.split('\\').last;
//
_provider.updateContent(widget.tabId, '', _fileName);
_controller.text = '';
//
final stream = file.openRead();
final lines = stream.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in lines) {
if (!mounted) break; //
setState(() {
_controller.text += '$line\n';
_provider.updateContent(widget.tabId, _controller.text, _fileName);
});
//
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
//
await Future.delayed(const Duration(milliseconds: 10));
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已加载: ${file.path}')));
}
} on FormatException {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('这不是可读的文本文件')));
}
} on FileSystemException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('文件访问错误: ${e.message}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('读取失败: ${e.toString()}')));
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}

313
win_text_editor/lib/app/widgets/text_editor.dart

@ -0,0 +1,313 @@ @@ -0,0 +1,313 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
class TextEditor extends StatefulWidget {
final String tabId;
final String? initialContent;
final String? fileName;
final Function(String, String?)? onContentChanged;
final Function(String)? onFileLoaded;
const TextEditor({
super.key,
required this.tabId,
this.initialContent,
this.fileName,
this.onContentChanged,
this.onFileLoaded,
});
@override
State<TextEditor> createState() => _TextEditorState();
}
class _TextEditorState extends State<TextEditor> {
late TextEditingController _controller;
late FocusNode _focusNode;
late ScrollController _scrollController;
bool _isLoading = false;
static const int maxFileSize = 1024 * 1024; // 1MB
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialContent ?? '');
_focusNode = FocusNode();
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
FocusScope.of(context).requestFocus(_focusNode);
});
}
@override
void didUpdateWidget(TextEditor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialContent != widget.initialContent) {
_controller.text = widget.initialContent ?? '';
}
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[100],
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'源文本${_controller.text.isEmpty ? '' : ' (${widget.fileName ?? ''}${_controller.text.length}字符)'}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.folder_open, size: 20),
tooltip: '打开文件',
onPressed: _isLoading ? null : () => _openFile(context),
),
IconButton(
icon: const Icon(Icons.content_copy, size: 20),
tooltip: '复制内容',
onPressed:
_controller.text.isEmpty
? null
: () => _copyToClipboard(context, _controller.text),
),
IconButton(
icon: const Icon(Icons.save, size: 20),
tooltip: '保存到文件',
onPressed:
_controller.text.isEmpty
? null
: () => _saveFile(context, _controller.text),
),
if (_isLoading)
const Padding(
padding: EdgeInsets.only(left: 8),
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
],
),
),
Expanded(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: true,
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: SingleChildScrollView(
controller: _scrollController,
child: Stack(
children: [
TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
onChanged: (text) {
widget.onContentChanged?.call(text, widget.fileName);
},
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
],
),
),
),
),
],
);
}
Future<void> _openFile(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false);
if (result != null && result.files.single.path != null) {
await loadFile(context, result.files.single.path!);
}
}
Future<void> _copyToClipboard(BuildContext context, String content) async {
await Clipboard.setData(ClipboardData(text: content));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制到剪贴板')));
}
}
Future<void> _saveFile(BuildContext context, String content) async {
try {
String? outputPath = await FilePicker.platform.saveFile(
dialogTitle: '保存文件',
fileName: 'untitled.txt',
allowedExtensions: ['txt'],
type: FileType.any,
);
if (outputPath == null) return;
final file = File(outputPath);
if (await file.exists()) {
final shouldOverwrite = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('文件已存在'),
content: const Text('要覆盖现有文件吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('覆盖'),
),
],
),
);
if (shouldOverwrite != true) return;
}
await file.writeAsString(content);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已保存到: ${file.path}')));
}
} on FileSystemException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存失败: ${e.message}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('保存失败: ${e.toString()}')));
}
}
}
Future<void> loadFile(BuildContext context, String filePath) async {
try {
setState(() => _isLoading = true);
final file = File(filePath);
final fileSize = await file.length();
//
if (fileSize > maxFileSize) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('文件过大(超过1MB),无法处理')));
}
return;
}
//
if (_controller.text.isNotEmpty) {
final confirm = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('确认提示'),
content: const Text('确认是否打开新的文件?打开后将覆盖当前内容'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirm != true) {
return;
}
}
final fileName = file.path.split('\\').last;
//
_controller.text = '';
widget.onContentChanged?.call('', fileName);
//
final stream = file.openRead();
final lines = stream.transform(utf8.decoder).transform(const LineSplitter());
await for (final line in lines) {
if (!mounted) break;
setState(() {
_controller.text += '$line\n';
widget.onContentChanged?.call(_controller.text, fileName);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
});
await Future.delayed(const Duration(milliseconds: 10));
}
widget.onFileLoaded?.call(file.path);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已加载: ${file.path}')));
}
} on FormatException {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('这不是可读的文本文件')));
}
} on FileSystemException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('文件访问错误: ${e.message}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('读取失败: ${e.toString()}')));
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
Loading…
Cancel
Save