9 changed files with 292 additions and 352 deletions
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class EditorToolbar extends StatelessWidget { |
||||
final String title; |
||||
final String text; |
||||
final bool isLoading; |
||||
final VoidCallback onOpenFile; |
||||
final VoidCallback onCopyToClipboard; |
||||
final VoidCallback onSaveFile; |
||||
|
||||
const EditorToolbar({ |
||||
super.key, |
||||
required this.title, |
||||
required this.text, |
||||
required this.isLoading, |
||||
required this.onOpenFile, |
||||
required this.onCopyToClipboard, |
||||
required this.onSaveFile, |
||||
}); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
height: 40, |
||||
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
decoration: BoxDecoration(color: Colors.grey[100]), |
||||
child: Row( |
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
children: [ |
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), |
||||
_buildActionButtons(context), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildActionButtons(BuildContext context) { |
||||
return Row( |
||||
children: [ |
||||
IconButton( |
||||
icon: const Icon(Icons.folder_open, size: 20), |
||||
tooltip: '打开文件', |
||||
onPressed: isLoading ? null : onOpenFile, |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.content_copy, size: 20), |
||||
tooltip: '复制内容', |
||||
onPressed: text.isEmpty ? null : onCopyToClipboard, |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.save, size: 20), |
||||
tooltip: '保存到文件', |
||||
onPressed: text.isEmpty ? null : onSaveFile, |
||||
), |
||||
if (isLoading) |
||||
const Padding( |
||||
padding: EdgeInsets.only(left: 8), |
||||
child: SizedBox( |
||||
width: 16, |
||||
height: 16, |
||||
child: CircularProgressIndicator(strokeWidth: 2), |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
@ -1,158 +1,226 @@
@@ -1,158 +1,226 @@
|
||||
import 'dart:ui'; |
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'text_editor_controller.dart'; |
||||
import 'text_editor_actions.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
|
||||
import 'editor_toolbar.dart'; // 添加这行导入 |
||||
|
||||
class TextEditor extends StatefulWidget { |
||||
final String tabId; |
||||
final String? initialContent; |
||||
final String? fileName; |
||||
final String title; |
||||
final Function(String, String?)? onContentChanged; |
||||
final Function(String)? onFileLoaded; |
||||
|
||||
const TextEditor({ |
||||
super.key, |
||||
required this.tabId, |
||||
this.initialContent, |
||||
this.fileName, |
||||
this.title = '未命名', |
||||
this.onContentChanged, |
||||
this.onFileLoaded, |
||||
}); |
||||
|
||||
const TextEditor({super.key, required this.tabId, this.initialContent, this.title = '未命名'}); |
||||
|
||||
@override |
||||
State<TextEditor> createState() => TextEditorState(); |
||||
} |
||||
|
||||
class TextEditorState extends State<TextEditor> { |
||||
late TextEditorController _editorController; |
||||
final TextEditingController _textController = TextEditingController(); |
||||
final FocusNode _focusNode = FocusNode(); |
||||
bool _isLoading = false; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_editorController = TextEditorController( |
||||
initialContent: widget.initialContent, |
||||
onContentChanged: (content, fileName) { |
||||
widget.onContentChanged?.call(content, fileName); |
||||
}, |
||||
onFileLoaded: widget.onFileLoaded, |
||||
); |
||||
_textController.text = widget.initialContent ?? ''; |
||||
// 移除监听器,改为在需要时直接获取内容 |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(TextEditor oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.initialContent != widget.initialContent) { |
||||
_editorController.updateContent(widget.initialContent ?? ''); |
||||
_textController.text = widget.initialContent ?? ''; |
||||
} |
||||
} |
||||
|
||||
String getContent() { |
||||
return _textController.text; |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
_editorController.dispose(); |
||||
_textController.dispose(); |
||||
_focusNode.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Column(children: [_buildToolbar(context), Expanded(child: _buildEditorField(context))]); |
||||
} |
||||
|
||||
Widget _buildToolbar(BuildContext context) { |
||||
return Container( |
||||
height: 40, |
||||
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
decoration: BoxDecoration(color: Colors.grey[100]), |
||||
child: Row( |
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
return Column( |
||||
children: [ |
||||
Text( |
||||
'${widget.title}${_editorController.isEmpty ? '' : ' (${widget.fileName ?? ''}${_editorController.contentLength}字符)'}', |
||||
style: const TextStyle(fontWeight: FontWeight.bold), |
||||
EditorToolbar( |
||||
title: widget.title, |
||||
text: _textController.text, |
||||
isLoading: _isLoading, |
||||
onOpenFile: () => _openFile(context), |
||||
onCopyToClipboard: () => _copyToClipboard(context), |
||||
onSaveFile: () => _saveFile(context), |
||||
), |
||||
_buildActionButtons(context), |
||||
Expanded(child: _buildEditorField(context)), |
||||
], |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildActionButtons(BuildContext context) { |
||||
return Row( |
||||
children: [ |
||||
IconButton( |
||||
icon: const Icon(Icons.folder_open, size: 20), |
||||
tooltip: '打开文件', |
||||
onPressed: |
||||
_editorController.isLoading |
||||
? null |
||||
: () => TextEditorActions.openFile(context, _editorController), |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.content_copy, size: 20), |
||||
tooltip: '复制内容', |
||||
onPressed: |
||||
_editorController.isEmpty |
||||
? null |
||||
: () => TextEditorActions.copyToClipboard(context, _editorController.content), |
||||
), |
||||
IconButton( |
||||
icon: const Icon(Icons.save, size: 20), |
||||
tooltip: '保存到文件', |
||||
onPressed: |
||||
_editorController.isEmpty |
||||
? null |
||||
: () => TextEditorActions.saveFile(context, _editorController.content), |
||||
Widget _buildEditorField(BuildContext context) { |
||||
return Container( |
||||
decoration: BoxDecoration( |
||||
color: _focusNode.hasFocus ? Colors.blue[50] : Colors.white, |
||||
border: Border.all(color: Colors.grey[300]!, width: 1.0), |
||||
), |
||||
if (_editorController.isLoading) |
||||
const Padding( |
||||
padding: EdgeInsets.only(left: 8), |
||||
child: SizedBox( |
||||
width: 16, |
||||
height: 16, |
||||
child: CircularProgressIndicator(strokeWidth: 2), |
||||
child: TextField( |
||||
controller: _textController, |
||||
focusNode: _focusNode, |
||||
maxLines: null, |
||||
expands: true, |
||||
keyboardType: TextInputType.multiline, |
||||
decoration: const InputDecoration( |
||||
border: InputBorder.none, |
||||
contentPadding: EdgeInsets.all(8), |
||||
), |
||||
style: const TextStyle(fontFamily: 'Courier New', fontSize: 16, color: Colors.black), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
Widget _buildEditorField(BuildContext context) { |
||||
return Container( |
||||
decoration: BoxDecoration( |
||||
color: _editorController.hasFocus ? Colors.blue[50] : Colors.white, |
||||
border: Border.all( |
||||
color: _editorController.hasFocus ? Colors.blue : Colors.grey[300]!, |
||||
width: _editorController.hasFocus ? 2.0 : 1.0, |
||||
), |
||||
), |
||||
child: ScrollConfiguration( |
||||
behavior: ScrollConfiguration.of(context).copyWith( |
||||
scrollbars: true, |
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse}, |
||||
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) async { |
||||
await Clipboard.setData(ClipboardData(text: _textController.text)); |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已复制到剪贴板'))); |
||||
} |
||||
} |
||||
|
||||
Future<void> _saveFile(BuildContext context) 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('取消'), |
||||
), |
||||
child: SingleChildScrollView( |
||||
controller: _editorController.scrollController, |
||||
child: ConstrainedBox( |
||||
constraints: BoxConstraints( |
||||
minHeight: MediaQuery.of(context).size.height - 40, // 减去工具栏高度 |
||||
TextButton( |
||||
onPressed: () => Navigator.pop(context, true), |
||||
child: const Text('覆盖'), |
||||
), |
||||
child: TextField( |
||||
controller: _editorController.textController, |
||||
focusNode: _editorController.focusNode, |
||||
maxLines: null, |
||||
onChanged: _editorController.handleContentChanged, |
||||
decoration: InputDecoration.collapsed(hintText: ''), |
||||
style: const TextStyle(fontFamily: 'Courier New', fontSize: 16, color: Colors.black), |
||||
], |
||||
), |
||||
); |
||||
|
||||
if (shouldOverwrite != true) return; |
||||
} |
||||
|
||||
await file.writeAsString(_textController.text); |
||||
|
||||
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 { |
||||
_isLoading = true; |
||||
final file = File(filePath); |
||||
|
||||
if (_textController.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; |
||||
} |
||||
|
||||
_textController.text = ''; |
||||
|
||||
final stream = file.openRead(); |
||||
final lines = stream.transform(utf8.decoder).transform(const LineSplitter()); |
||||
|
||||
int lineCount = 0; |
||||
|
||||
await for (final line in lines) { |
||||
_textController.text += '$line\n'; |
||||
|
||||
lineCount++; |
||||
if (lineCount >= 300) { |
||||
return; //仅加载前300行 |
||||
} |
||||
|
||||
await Future.delayed(const Duration(milliseconds: 10)); |
||||
} |
||||
|
||||
void setContent(String content) { |
||||
_editorController.updateContent(content); |
||||
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 { |
||||
_isLoading = false; |
||||
} |
||||
} |
||||
} |
||||
|
@ -1,163 +0,0 @@
@@ -1,163 +0,0 @@
|
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'text_editor_controller.dart'; |
||||
|
||||
class TextEditorActions { |
||||
static Future<void> openFile(BuildContext context, TextEditorController controller) async { |
||||
final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: false); |
||||
|
||||
if (result != null && result.files.single.path != null) { |
||||
await _loadFile(context, controller, result.files.single.path!); |
||||
} |
||||
} |
||||
|
||||
static 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('已复制到剪贴板'))); |
||||
} |
||||
} |
||||
|
||||
static 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()}'))); |
||||
} |
||||
} |
||||
} |
||||
|
||||
static Future<void> _loadFile( |
||||
BuildContext context, |
||||
TextEditorController controller, |
||||
String filePath, |
||||
) async { |
||||
try { |
||||
controller.setLoading(true); |
||||
final file = File(filePath); |
||||
final fileSize = await file.length(); |
||||
|
||||
if (fileSize > controller.maxFileSize) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of( |
||||
context, |
||||
).showSnackBar(const SnackBar(content: Text('文件过大(超过1MB),无法处理'))); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (!controller.isEmpty) { |
||||
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.updateContent(''); |
||||
controller.onContentChanged?.call('', fileName); |
||||
|
||||
final stream = file.openRead(); |
||||
final lines = stream.transform(utf8.decoder).transform(const LineSplitter()); |
||||
|
||||
await for (final line in lines) { |
||||
if (!controller.textController.hasListeners) break; |
||||
|
||||
controller.textController.text += '$line\n'; |
||||
controller.onContentChanged?.call(controller.content, fileName); |
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
||||
controller.scrollController.jumpTo(controller.scrollController.position.maxScrollExtent); |
||||
}); |
||||
|
||||
await Future.delayed(const Duration(milliseconds: 10)); |
||||
} |
||||
|
||||
controller.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 { |
||||
controller.setLoading(false); |
||||
} |
||||
} |
||||
} |
@ -1,42 +0,0 @@
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class TextEditorController { |
||||
final TextEditingController textController; |
||||
final FocusNode focusNode; |
||||
final ScrollController scrollController; |
||||
final Function(String, String?)? onContentChanged; |
||||
final Function(String)? onFileLoaded; |
||||
|
||||
bool _isLoading = false; |
||||
static const int _maxFileSize = 1024 * 1024; // 1MB |
||||
|
||||
TextEditorController({String? initialContent, this.onContentChanged, this.onFileLoaded}) |
||||
: textController = TextEditingController(text: initialContent ?? ''), |
||||
focusNode = FocusNode(), |
||||
scrollController = ScrollController(); |
||||
|
||||
bool get isLoading => _isLoading; |
||||
bool get hasFocus => focusNode.hasFocus; |
||||
bool get isEmpty => textController.text.isEmpty; |
||||
String get content => textController.text; |
||||
int get contentLength => textController.text.length; |
||||
int get maxFileSize => _maxFileSize; |
||||
|
||||
void updateContent(String content) { |
||||
textController.text = content; |
||||
} |
||||
|
||||
void handleContentChanged(String text) { |
||||
onContentChanged?.call(text, null); |
||||
} |
||||
|
||||
void setLoading(bool loading) { |
||||
_isLoading = loading; |
||||
} |
||||
|
||||
void dispose() { |
||||
textController.dispose(); |
||||
focusNode.dispose(); |
||||
scrollController.dispose(); |
||||
} |
||||
} |
Loading…
Reference in new issue