8 changed files with 349 additions and 305 deletions
@ -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…
Reference in new issue