|
|
|
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';
|
|
|
|
|
|
|
|
class TextTab extends StatefulWidget {
|
|
|
|
final String tabId;
|
|
|
|
|
|
|
|
const TextTab({super.key, required this.tabId});
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<TextTab> createState() => TextTabState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class TextTabState extends State<TextTab> {
|
|
|
|
late TextEditingController _controller;
|
|
|
|
late EditorProvider _provider;
|
|
|
|
late FocusNode _focusNode;
|
|
|
|
late ScrollController _scrollController;
|
|
|
|
bool _isLoading = false;
|
|
|
|
static const int maxFileSize = 1024 * 1024; // 1MB
|
|
|
|
|
|
|
|
@override
|
|
|
|
void initState() {
|
|
|
|
super.initState();
|
|
|
|
_provider = Provider.of<EditorProvider>(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(TextTab oldWidget) {
|
|
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
if (oldWidget.tabId != widget.tabId) {
|
|
|
|
_controller.text = _getCurrentContent();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_controller.dispose();
|
|
|
|
_focusNode.dispose();
|
|
|
|
_scrollController.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final tab = _provider.tabs.firstWhereOrNull((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,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|